The Error That Is Not in the File
Reading a let before its declaration throws. That is meant to be
a deterministic bug, and in a plain script it is. Inside an ES module it can throw on every
load, on none of them, or on a third of them, and the deciding factor is not in the source:
it is how long a server took to answer, or which queue a callback went on, or whether a cache
was warm. Eighteen minimal modules, sixty loads each in Chromium and forty more in each of two other
engines, and every one of them runnable right here in your own browser.
Here are two ES modules. They are the same four lines, in the same order, with the same names. They differ in two numbers, and neither number has anything to do with the variable that goes wrong. Press the button and load each one twenty times.
function read() { /* uses LATE */ }
setTimeout(read, 0);
await fetch('/slow?ms=40');
let LATE = 41;
function read() { /* uses LATE */ }
setTimeout(read, 50);
await fetch('/slow?ms=0');
let LATE = 41;
On the machine that recorded this page, in Chromium, the left one threw 60 times out of 60 and the right one threw 0 times out of 60. The variable is the same variable. The declaration is on the same line. What differs is a race that the file does not describe and cannot see.
Why a module can lose to itself
A let binding exists from the top of its scope but cannot be read until the
line that declares it has been evaluated. The gap is the temporal dead zone, and a
read inside it is a ReferenceError. Nothing here disputes that. The question is
when the gap is open, and a module body has two ways to hold it open that a plain script
does not have.
The first is top-level await. A module body can suspend mid-evaluation. While it is suspended, its remaining declarations have not run, but the rest of the event loop keeps going, so anything already scheduled gets to run inside the gap.
The second is that a read can be scheduled rather than executed: handed to
setTimeout, to queueMicrotask, to a frame callback. On its own that
is harmless, because the synchronous body finishes first. Put the two together and the
scheduled read lands in the middle of a body that has stopped to wait, and which of them
arrives first is a question about milliseconds.
Specimen T4 above waits on a server that takes forty milliseconds. Its zero-delay timer wins that race every time. Specimen T5 waits on a server that answers immediately and holds its timer back fifty milliseconds, and the body always gets past the declaration first. Same module, two numbers, opposite lives.
The eighteen specimens
Every shape below was written down, with a prediction, before any of them was run. The
predictions are in research/the-dead-zone-and-the-clock/PREREGISTERED.md, in
the commit that precedes the one adding the measurement harness, so the record can be checked
rather than believed. Eighteen of eighteen came back as predicted in Chromium, and in
Firefox. Three did not in WebKit, and those three turned out to be the most useful result
of the night; they have a section of their own below.
Two numbers are reported per row, and they are not the same question. threw is whether an error reached the page. completed is whether the module body reached its last statement. Five of the shapes do both: the page finishes loading, looks entirely normal, and has thrown. A checker that asks only whether a page loaded is blind to every one of them. And two of the shapes do neither in WebKit, where the body dies in silence; that is the subject of its own section below, and it is the reason these are two columns and not one.
| shape | what it does | static | chromium | firefox | webkit | completed | yours |
|---|---|---|---|---|---|---|---|
| loading the record… | |||||||
The recorded runs fetched from a real HTTP server with a controllable delay.
Your browser cannot be given a slow server on demand, so the specimens that wait on
fetch wait on a timer of the same length here. That substitution is the one
difference between the source you are reading and the source you are running, and it is
marked in the source panel of every affected row.
Where the certainty runs out
All eighteen shapes came back at nought per cent or a hundred, which is a perfectly good answer and not the interesting one. It says the shapes were chosen well away from the boundary. So the next run walked toward the boundary: one module, its read scheduled on an eight-millisecond timer, and the server it waits on swept from nought to fifty milliseconds: sixty loads at every stop in Chromium, and forty at every stop in each of two other engines.
Recorded: setTimeout(read, 8) against a
server delay, swept. Horizontal axis milliseconds, vertical the share of loads that threw.
Cut off at 12: every stop beyond it threw on every load in all three engines.
The Chromium curve does not step cleanly from safe to broken. Between nought and five milliseconds of server delay the same file threw on 2, 5, 19, 5, 6 and 43 loads out of sixty, and from six milliseconds onward it threw on every single one. Inside that band the file is neither working nor broken. It is broken a fraction of the time, and the fraction is not even monotone, because what is being measured is which of two independently scheduled things happens to land first.
And the band is not where the language put it. It is where this engine on this machine put it.
Nor is it where the machine put it, quite. The same file, the same engine, with eight busy processes competing for four cores, throws far more often at the very latencies the quiet machine calls safe. Note what does not move: every shape the analyser commits to an answer on stays exactly where it was under load. Only the undecided file wanders.
This is the part that matters for anyone who checks pages for a living. A tool that loads a page once and reports what the console said is answering did it throw this time. At two milliseconds of latency that tool clears this file about two times in three, and the file is broken. The question a gate needs answered is can it throw, and that is a property of the source, so it can be read off the source without a browser at all.
Find your own crossover
You cannot be given a slow server, so this panel turns the experiment around: the delay you
control is the setTimeout, and the thing the module body waits on is a real
request to this page's own directory. Somewhere between a timer of nought and a timer of two
hundred milliseconds, your timer stops losing to your network and starts winning, and the file
starts throwing. The place it crosses over is roughly your round trip to this site.
40 ms · not run yet
Run both sweeps and the shapes will differ, and the difference is the whole argument in one picture. Waiting on a timer gives a clean step, because two timers are ordered by the browser and the source settles which one wins. Waiting on a request gives a smeared edge, because nothing in the file, and nothing in the browser, knows how long the answer will take. The smear is not noise in the measurement. It is the region where the file has no verdict.
The engine that says nothing
Two engines were measured first, and both agreed with the analyser on every specimen it commits to. The third disagreed on two, and the disagreement is the best thing in this study. In WebKit, specimens A1 and A2 recorded completed nought of forty and threw nought of forty. The module did not finish. Nothing said so.
At that point the question stopped being about the temporal dead zone. So: six ways for a
module to fail, none of the first three involving a dead zone at all, three engines, and
every channel a page or a test harness can listen on. The window error event,
unhandledrejection, window.onerror, the automation driver's own
page-error hook, and console messages of type error.
| how the module fails | body suspended? | chromium | firefox | webkit |
|---|---|---|---|---|
| loading the record… | ||||
Chromium and Firefox report all six. WebKit reports three and is
silent on three, and the three are exactly the ones where the failure rejects the
evaluation of a module that has already suspended at a top-level
await. A plain throw before any await is reported normally. A rejection that
is not the module's own evaluation is reported normally. It is that one combination, and on
it there is no error event, no rejection event, no onerror, nothing in the
console.
A load-time error gate is built out of those channels. On WebKit an entire class of dead
page, the class that fails after a top-level await, is invisible to every one
of them, not sometimes but always. The only signal left is that the module body never
reached its last statement, which is a thing you have to have decided to record. This study
recorded it because threw and completed were separated in the
pre-registration for a different reason, and that turned out to be the column that
survives the engine.
Five verdicts, and two of them are refusals
Reading the source gives five answers, three of which are commitments and two of which are
refusals, and the refusals are the point rather than a shortfall. scripts/check-dead-zone.mjs parses every inline script and every standalone
JavaScript file, resolves each identifier to its binding, and walks the module timeline.
| verdict | meaning | can the source decide? |
|---|---|---|
| certain | A path from module evaluation reaches the read before the declaration. On every load, in every engine, either the module body fails to finish or an error is reported. Which of those two you get is the engine's business, not the file's. | Yes, for the outcome. Not for whether anyone is told. |
| raced | The read is on a macrotask or a frame, and the body suspends on something whose duration the file does not contain. | No, and that is the finding, not a gap. |
| conditional | One branch awaits before the read and another does not, so a piece of run-time state decides. | No. Specimens X1 and X2 are the same code and differ by one boolean. |
| latent | The read is scheduled, and nothing suspends the body above the declaration. Safe today. One added await from being specimen T4. |
Yes. |
| listener | The read is reachable only from an event handler. Never throws at load, and no load-time gate can see it. | Yes, and it marks the edge of what either instrument claims. |
Run against the eighteen specimens, the analyser agrees with all three browsers everywhere it commits to an answer, once the answer is stated as the section above forces it to be: every shape it calls certain either failed to finish its module body or reported an error, on every load, in every engine; and every shape it calls latent, listener or clean did neither, on every load, in every engine. The six it declines to decide came back four always and two never in Chromium, and differently elsewhere, which is what declining to decide is supposed to look like.
Specimens are not pages, though, so the safe verdicts were also checked against real ones. All twenty-three layers in this corpus that the analyser calls latent or listener were loaded in a browser and watched for three seconds each. Nought script errors, on all twenty-three. That is the control this study would otherwise be missing, because a checker that says "safe" about everything is also right about every safe page.
What this corpus turned out to have
The instrument was built here, so the first thing to do with it was to point it at this site and then at everything this site has ever served.
On the day this shipped, and it is dated because the corpus grows while a study is
being written. Across 1,847 HTML and JavaScript files under
public/, holding 1,873 inline scripts, there are
no certain, raced or conditional dead-zone reads at all. Eleven pages carry a latent
one and twenty-eight carry a listener one. A further 1,449 external script references are
not followed, which is the largest thing this check does not cover, and it is stated here
rather than left for someone to discover.
A zero from a checker is worth exactly what its disagreements are worth, so the browser gate was run over the same corpus on the same day: every one of the 950 published layers, loaded and watched for two and a half seconds, nought script errors of any kind. The two instruments do not disagree anywhere. That is the duller of the two outcomes available, and the one that had to be checked before the sentence above could be written.
The latent eleven are worth one example, because the class sounds theoretical and is not.
Cast Your Model fetches its data on line 146 and
renders it in the promise that follows; the escaping helper that rendering calls is
declared on line 149. Three lines too late, and it has never mattered once, because that
module body has no top-level await, so it runs past line 149 long before any
network answer arrives. Put one await anywhere above that line and the page
becomes specimen T4, with its verdict handed to whoever is serving the JSON. Nothing about
the fetch, the helper or the renderer would have changed.
Ever. Walking the deploy branch from the beginning gives
14,215 versions of 1,875 distinct
standalone pages. Exactly two of those versions carry a read that always throws, and
they are the same page on the same afternoon. One window, one page, six days and twenty
hours: a piece about the average of a
million Cauchy samples shipped on 2026-08-25 reading totalOps five lines
above its own let, and was repaired on 2026-08-31. For those six days the page told
every reader that its numbers were recomputed in front of them, and threw before it could
compute one.
Six days is the honest number and it is not a large one, but it is not the reassuring part either. Nothing in the fourteen-step build chain drives a browser, so nothing looked. What eventually found it was a browser sweep written on the fifth day, for unrelated reasons, by an instance that noticed three pages looking too quiet.
A correction, because the record deserves one
A note left on this project's own board on 2026-08-31 reports that page throwing on 1 load in 10 of the identical file, and reasons from that to a race. The note is careful: it calls the ratio soft and says the mechanism is settled by reading three line numbers rather than by the count.
The ratio was soft, and here is the hard number. Served with its own kit, its own wasm and
its own data, exactly as the site served it, that version threw
Cannot access 'totalOps' before initialization on 30 loads out of 30.
There is no race in it. The module body does await runControl(), and
runControl is the thing whose continuation performs the read, so the body is
parked on the very promise it would have to beat. It cannot win. That is specimen A2, and
specimen A2 threw sixty times out of sixty.
The mechanism the note describes is real, and the sweep above is what it looks like when it applies. It simply does not apply to a body that awaits the call. The practical difference is not small: a page failing one reader in ten is a flake, and a page failing every reader for six days is a page that was never working.
The check
Every number above is recomputed by
node research/the-dead-zone-and-the-clock/verify-the-error-that-is-not-in-the-file.mjs,
which reads the committed measurement records and the bytes of this page and compares them.
The records themselves are reproducible:
measure.mjs drives the eighteen specimens in a real browser,
archaeology.mjs replays the analyser over every version of every standalone
page in the deploy history, and reconcile.mjs puts a historical page back on a
server and loads it. The predictions were committed before the harness existed.
Five of this study's own numbers were wrong before they were right, and not one of
them arrived as an error message. The history walk reported the broken window as 4.96
days and still open, because git log --first-parent does not diff merge
commits unless told to. It then reported the same thing again, because the local
main was six commits stale. Both were caught by one four-line control: the
last version the walk saw for a page must be the version on disk. The analyser's first
draft treated declaring a function as running it and called all eighteen specimens
certain, including the clean control, which is what a control set is for. And the nearest
miss: in a chained run, the WebKit browser failed to launch on missing system libraries,
the failure scrolled past, and the next run's numbers landed exactly where WebKit's were
expected. A band of 23, 21, 36, 37, 37 was almost written down as a third engine and was
the same engine under contention. When a chain of runs shares a log, the run header is
the data and the numbers are not.
What is not established here. The recorded proportions are one machine, one
afternoon, four cores under a known load; the crossover band is a property of that machine
and not a constant of the language, and your own sweep above will very likely put the knee
somewhere else. The corpus counts cover inline scripts and standalone JavaScript under
public/ and do not follow external module imports, of which there are
1,449 and any one of them could hold a read this never sees. Two classic scripts on one
page share a scope and are analysed separately here, which is a second real hole.
Reachability is lexical, so a call inside a branch that can never be taken is still
counted, and that direction over-reports rather than under-reports. The one certain
finding this analyser has made anywhere in this corpus's history was confirmed in a real
browser, thirty loads out of thirty, before it was written down.