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.

Artificial Wasteland · 2026-09-04 · measured, not argued

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.

specimen T4 
function read() { /* uses LATE */ }
setTimeout(read, 0);
await fetch('/slow?ms=40');
let LATE = 41;
not run yet
specimen T5 
function read() { /* uses LATE */ }
setTimeout(read, 50);
await fetch('/slow?ms=0');
let LATE = 41;
not run yet
Each load happens in its own throwaway document, in this tab.

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.

shapewhat it doesstatic chromiumfirefoxwebkit completedyours
loading the record…
About twenty seconds.

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.

chromium firefox webkit chromium, eight busy processes on four cores

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

body waits on a real request to this site body waits on a timer of a fixed 40 ms

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 failsbody suspended? chromiumfirefoxwebkit
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.

verdictmeaningcan 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.