Fixing async Script Race Conditions
Diagnosis: a page with two or more async scripts throws Uncaught TypeError: Cannot read properties of undefined from the consumer script on a minority of loads — never on localhost, never reliably in staging, and always with both files present and returning 200 — because async scripts execute in the order their bytes finish arriving, not in the order you declared them.
Root cause: async puts scripts in a set, not a list
The HTML Standard’s prepare the script element algorithm routes every script into exactly one destination, and async on an external script routes it into the set of scripts that will execute as soon as possible. The word set is the whole bug. Deferred scripts go into a list, which is ordered and drained in document order after parsing. The async set has no order: each element leaves it the moment its own fetch completes and its compilation is ready, independently of every other member. Two async tags therefore encode no relationship at all — not “roughly in order”, not “usually in order”. The parser’s document order is discarded the instant both elements are classified.
What decides the actual order is the completion time of two independent fetches, and every input to that is variable between loads on the same device. Compressed transfer size dominates, so a 140 KB library normally loses to an 18 KB widget, but the margin is set by things you do not control: which requests share the connection and how the server splits bandwidth between concurrent streams, whether one file is already in the disk cache from a previous visit, whether a hashed filename changed on the last deploy and evicted only one of the two, TLS and connection-establishment cost for a third-party origin, server think time, and a single TCP retransmit adding a round trip to one stream and not the other. Chromium fetches every async and defer script at Low priority, so both files also sit behind the stylesheet and the LCP image in the same priority queue and compete with each other for whatever bandwidth is left.
The result is a probability distribution, not a bug that either exists or does not. The page fails on the fraction of loads where the completion times cross over, and that fraction moves with your users’ networks, your CDN’s cache hit ratio and your own deploy cadence. The timeline below shows the same markup on the same device across two consecutive loads: widget-init.js (18 KB) and vendor-charts.js (140 KB), both async, on a Fast 4G profile.
The two panels differ only in the cache state of one file. Nothing was deployed between them, no attribute changed, and the second load is the one that “works” — which is precisely why the bug survives code review, staging and a manual reload. The full classification these two files fall into is set out on script loading: async, defer and execution order; this guide is about the failure that classification produces and how to remove it.
The failure is also not always a TypeError. When the consumer defensively guards its access, the same race turns into a silent one: a feature that quietly does not initialise, an event listener registered after the event it was meant to catch, or a double initialisation when both files independently decide they are first. Those cost more to find than a thrown exception, and they come from the same root cause.
Why the failure rate looks random
Because the outcome is decided by a byte-arrival margin, the interesting quantity is not “does it fail” but “how wide is the window, and how often does it close”. Four states of the same two files, measured across one week of field data, produce four different answers.
The last row deserves attention because it is the state your own browser is usually in. A 4 ms margin is not a guarantee; it is a coin weighted slightly in your favour. One extra kilobyte in the library, one cache eviction, one busy CPU delaying a compile, and that row joins the two above it.
Minimal reproduction
The complete bug is three lines of markup and one line of JavaScript. Nothing here is malformed and nothing 404s.
<!-- Both elements are classified into "the set of scripts that will execute as
soon as possible". A set has no order, so the browser is free to execute
either one first; document order is discarded at classification time. -->
<script src="/js/vendor-charts.js" async></script>
<script src="/js/widget-init.js" async></script>
// widget-init.js — the consumer. Correct in isolation; broken by scheduling.
// On any load where these 18 KB complete before the library's 140 KB, this line
// runs against an undefined global and the whole file aborts at statement one.
window.Charts.render('#sales', salesData);
To turn the intermittent exception into a number, emit a mark as the very first statement of each bundle and report the sign of the gap. This is the only reliable way to see the inversion rate before your users do:
// First statement of vendor-charts.js and of widget-init.js respectively.
// Scheduling rationale: document order is what you wrote, mark order is what the
// scheduler produced. Marks are the cheapest way to record the difference,
// because they are captured before any of the code that might throw runs.
performance.mark('exec:vendor-charts');
// ...and from a load handler, once both marks can exist:
addEventListener('load', () => {
const at = (n) => performance.getEntriesByName(n)[0]?.startTime ?? -1;
const producer = at('exec:vendor-charts');
const consumer = at('exec:widget-init');
navigator.sendBeacon('/rum/script-order', JSON.stringify({
// Negative margin means the consumer executed first: this load was a failure
// even if nothing threw, because the ordering assumption did not hold.
marginMs: Math.round(consumer - producer),
inverted: consumer < producer,
}));
});
Run that for a day. A median margin of 400 ms with a 2% inversion rate and a median margin of 12 ms with a 0% inversion rate are the same bug at different distances from the cliff, and both need the same fix.
Deterministic fix protocol
Work through these in order. Steps 1 to 3 are diagnosis and are cheap; steps 4 to 7 are four mutually exclusive fixes — pick the first one you are allowed to apply — and step 8 stops the fix from silently regressing.
- [ ] 1. Confirm it is an ordering race and not a missing dependency. Reload with the cache disabled, then reload again with the producer URL added to DevTools request blocking. A genuinely missing file fails identically on every load; an ordering race fails on some loads and succeeds on others with the file present and returning 200. If the Console error only ever appears with a cold cache, you have already localised it.
- [ ] 2. Measure the race window before changing anything. Ship the
performance.markpair above and beacon the margin. You need the inversion rate to know whether you are fixing a live incident or hardening a 4 ms margin, and you need the baseline to prove the fix later. - [ ] 3. Classify each script as producer, consumer or independent. A producer defines globals another file reads; a consumer reads them; an independent script does neither. Write the list down — the classification, not the attribute, is what determines the correct fix. Only a genuinely independent script may keep
async. - [ ] 4. Move every producer/consumer pair from
asynctodefer. Both entries then land in the ordered list that drains in document order after parsing. The fetches still happen in parallel at the same Low priority, so this costs no network time at all; it constrains only execution order, which is the thing that was broken. - [ ] 5. Clear the force async flag on injected scripts. Any element from
document.createElement('script')starts with force async set, so a loader that appends a chain gets a race even though the wordasyncappears nowhere. Assignscript.async = falsebefore insertion, and append the chain in a singleDocumentFragmentso another loader’s insertions cannot interleave with yours. - [ ] 6. Install a ready-queue stub where you control only one side. When the producer is a third-party file you cannot reorder, buffer the consumer’s calls in an inline stub and drain them when the real implementation arrives. This makes both execution orders produce identical behaviour instead of trying to force one of them.
- [ ] 7. Convert real dependencies into
importedges. An ES module import is a dependency the browser evaluates in graph order regardless of arrival order — the strongest guarantee available, and the only one that survives a bundler reshuffling your tags. - [ ] 8. Lock the ordering with a test that delays the producer. A test on a warm local cache passes for the wrong reason. Intercept the producer’s response, delay it past the consumer, and assert both that the page works and that the marks land in the intended order.
Step 4: the two-line fix that costs nothing
<!-- Scheduling rationale: deferred external classic scripts share one list that
drains in DOCUMENT order after parsing, so this pair is now an ordering
guarantee rather than a probability. Both files are still fetched in
parallel at Low priority — identical bytes, identical waterfall, and the
only thing that changed is which of them is allowed to execute first. -->
<script src="/js/vendor-charts.js" defer></script>
<script src="/js/widget-init.js" defer></script>
The cost is honest and worth stating: the consumer now executes after parsing rather than on arrival, so on a cold load it runs at 664 ms instead of 182 ms. It also runs correctly, which the 182 ms version did not.
Step 5: forcing order on an injected chain
// Scheduling rationale: createElement sets the element's "force async" flag, so
// these two would race exactly like two async tags. Assigning to the .async IDL
// attribute clears that flag and moves each element into "the list of scripts
// that will execute in order as soon as possible" — parallel fetches, serialised
// execution, insertion order preserved, and no wait for the parser to finish.
function loadInOrder(urls) {
const frag = document.createDocumentFragment();
for (const url of urls) {
const s = document.createElement('script');
s.src = url;
s.async = false; // NOT the same as omitting it: this clears force async
frag.appendChild(s);
}
// A single insertion keeps the chain contiguous. The in-order list is global to
// the document, so appending one element at a time lets another loader's script
// interleave between yours and gate your chain on its slowest file.
document.head.appendChild(frag);
}
loadInOrder(['/js/vendor-charts.js', '/js/widget-init.js']);
Step 6: the ready-queue handshake
When the producer is a third-party tag you cannot reattribute, stop trying to win the race and make the order irrelevant. An inline stub is parser-inserted, so it runs at its parse position and is guaranteed to exist before either async file can execute.
<script>
// Scheduling rationale: this runs at its parse position, which is strictly
// before any async script can execute, so every call site can rely on the
// function existing. Calls made before the real library arrives are buffered
// rather than thrown away, and the ordering assumption disappears entirely.
window.charts = window.charts || function () {
(window.charts.q = window.charts.q || []).push(arguments);
};
</script>
// Last statement of vendor-charts.js. Scheduling rationale: replacing the stub and
// draining the buffer in insertion order makes both execution orders converge on
// the same observable result — calls made early are replayed, calls made late go
// straight through, and neither path depends on which file's bytes landed first.
const queued = (window.charts && window.charts.q) || [];
window.charts = realCharts;
for (const args of queued) realCharts.apply(null, args);
The state machine below is what that pattern actually implements. Both entry paths — a consumer that calls early and a library that arrives first — end in the same steady state.
Step 7: an import edge is the strongest guarantee
<!-- Scheduling rationale: an import is a dependency edge the browser evaluates in
graph order — widget-init cannot evaluate before vendor-charts has evaluated,
whichever file's bytes arrive first. modulepreload issues the dependency's
fetch immediately, because the preload scanner sees only the entry URL and
would otherwise discover the import one full round trip later. -->
<link rel="modulepreload" href="/js/vendor-charts.js">
<script type="module" src="/js/widget-init.js"></script>
This is the structural fix rather than the scheduling one, and it is worth the migration when the same pair keeps reappearing. The chunk-graph mechanics are covered in modulepreload and ES module loading.
Step 8: a test that actually exercises the race
// Scheduling rationale: without the delay this test passes on a warm cache no
// matter how the scripts are attributed, because both responses complete in the
// same millisecond. Forcing the producer to arrive LAST recreates the exact
// arrival order that fails in the field, so the assertion has something to catch.
test('the widget survives the library arriving last', async ({ page }) => {
await page.route('**/vendor-charts.js', async (route) => {
await new Promise((r) => setTimeout(r, 1500)); // producer arrives last
await route.continue();
});
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.goto('/dashboard');
await page.waitForLoadState('load');
expect(errors).toEqual([]);
// Order assertion, not just absence of errors: a defensive guard can hide the
// race by silently skipping initialisation, which this catches and errors do not.
await expect(page.locator('#sales canvas')).toBeVisible();
});
Before and after
Measured on a Fast 4G profile (9 Mbps, 85 ms RTT, 4× CPU throttling), cold cache, 10 000 synthetic loads plus seven days of field data from the same page. The third column is the ready-queue variant, which keeps async on both files.
| Metric | Two async tags |
defer pair |
async + ready queue |
|---|---|---|---|
| Order inversions per 10 000 loads | 1 842 | 0 | 0 (order irrelevant) |
TypeError rate, field, 7 days |
1 in 54 loads | 0 in 238 000 | 0 in 238 000 |
| Producer executes, p50 | 613 ms | 648 ms | 613 ms |
| Consumer executes, p50 | 182 ms | 664 ms | 182 ms, replayed at 616 ms |
| Chart first painted, p50 | 620 ms (82% of loads) | 690 ms | 655 ms |
DOMContentLoaded |
268 ms | 672 ms | 268 ms |
load event |
690 ms | 702 ms | 688 ms |
| Total blocking time | 210 ms | 205 ms | 214 ms |
| Transferred bytes | 158 KB | 158 KB | 158.3 KB |
Three rows carry the decision. The inversion count going to zero is the fix. The DOMContentLoaded row is what defer costs — 404 ms, because the deferred list now gates the event on the 140 KB library — and it is the reason the ready-queue variant exists at all. Total blocking time barely moves in either column, confirming that this was never a CPU problem: the compile and execute cost is identical, only its ordering changed. If your own numbers show total blocking time shifting after an attribute-only change, something else moved at the same time, most often a bundler splitting chunks differently.
For third-party tags, where you usually cannot move the file into the deferred list at all, the same trade is analysed in deferring third-party scripts without breaking analytics.
FAQ
Q: Does fetchpriority="high" on the producer fix the ordering?
No, and relying on it is how a rare failure becomes a rarer one that nobody can reproduce. fetchpriority changes when the bytes arrive, never when the code runs, and async execution is still triggered by arrival. On the page measured above, promoting the 140 KB producer to high cut the inversion rate from 18.4% to 3.1% — a real improvement in the wrong dimension. It cannot reach zero, because a single retransmit, a busy CPU delaying one compile, or a warm cache entry on the consumer inverts the order again. Use priority to fix bandwidth contention; use defer, the in-order list or an import edge to fix ordering.
Q: Why does the race never reproduce on localhost?
Because the race window collapses. On a loopback connection with a warm disk cache both responses complete within a couple of milliseconds of each other and are delivered over the same connection in request order, so execution follows document order by accident rather than by rule. Production margins are hundreds of milliseconds wide and both signs are common. Reproduce it deliberately instead: throttle to a slow profile, disable the cache, or intercept the producer and delay it — the delay in the step 8 test exists for exactly this reason.
Q: Can I wrap the consumer in a DOMContentLoaded listener instead?
That fixes DOM-readiness races, not script-to-script ordering, and the two are frequently confused. An async script does not delay DOMContentLoaded, so a slow producer can easily still be in flight when the event fires — the listener then runs against the same undefined global, just later. Worse, if the consumer’s own bytes arrive after DOMContentLoaded, the listener is registered after the event has already fired and never runs at all, converting a loud TypeError into a silent no-op. Fix the ordering, and use DOMContentLoaded only for what it actually guarantees: a fully parsed DOM.