Faster and Flakier: How PageSpeed Optimizations Can Introduce Race Conditions

  • 6 min
  • Written by Markus Milkereit
A runner waiting on a racing block for the starting sign.
Everyone starts at the same time. The network decides who arrives first, not you.

The slider works on your machine and stays dead on a phone with one bar of signal, while the console is clean and Lighthouse is green. Performance work moves scripts off the critical path, which removes an ordering you used to get for free.

A classic <script> blocks the parser, so later code can rely on it; with async, injected scripts or lazy loading, that code starts to depend on timing. On your laptop the same script wins every time, on a slow connection the wrong one wins, and the lasting fix is to declare dependencies explicitly.

The usual suspects at a glance

Five patterns cause most of the trouble. Each is an unwritten assumption about order.

  • async instead of defer:
    scripts run in arrival order, so a plugin can beat its library.
  • Inline script assumes a library:
    $ is not defined, because the library is deferred and the snippet isn't.
  • Event already fired:
    a late script waits for a DOMContentLoaded that has long passed.
  • Consent and tracking:
    analytics asks for a consent state that isn't set yet.
  • Out-of-order responses:
    a slow old request overwrites a newer result.

What is a race condition in PageSpeed optimization?

A race condition means the result depends on which of several competing tasks finishes first. The term comes from electronics, where two signals take different paths. In the browser the runners are scripts, stylesheets and API responses, and the track is a network you don't control.

Optimization costs you the order guarantee, though not in one way. async and injected scripts run when they arrive (scripts created with createElement('script') are async by default unless you set script.async = false). defer and modules keep document order but run after parsing. The HTML Standard defines this, and the MDN <script> reference summarizes it.

Timeline of three scripts on a fast and a slow connection: on the fast one they run in document order, on the slow one the plugin arrives before its library and fails. AI generated
Same three scripts, different network: on the slow connection the plugin wins the race against its own library.
Loading style When it runs Order kept? Notes
Classic <script> Immediately, blocks parsing Yes The default, and the slowest for first render
async As soon as it is downloaded No May run before or after parsing has finished
defer After parsing, before DOMContentLoaded Yes, document order External classic scripts only, no effect on inline scripts
type="module" After parsing, like defer Yes, document order Inline modules are deferred too
type="module" with async As soon as it is available No Ordering holds only without the async attribute

What do these race conditions look like in code?

Each suspect below gets the same treatment: the problem, then one code block showing the broken and the fixed version.

async instead of defer: what is the difference?

async scripts run as soon as they download, in whatever order that happens. defer scripts run after parsing, in document order. Swapping defer for async "for speed" breaks every script that depends on another. Fix: use defer for both.

<!-- Broken: the plugin may run before its host library -->
<script async src="/assets/slider-lib.js"></script>
<script async src="/assets/slider-plugin.js"></script>

<!-- Fixed: both run after parsing, in this order -->
<script defer src="/assets/slider-lib.js"></script>
<script defer src="/assets/slider-plugin.js"></script>

"$ is not defined": inline scripts that assume a library is ready

The classic message is Uncaught ReferenceError: $ is not defined (Firefox and Safari word it differently). The library is deferred, the small inline snippet in the template is not, so the snippet runs first. jQuery is only the example; any deferred or async library behaves the same. Inline classic scripts can't be deferred, and async does nothing for them. Fix: move the code into a deferred file, or keep it inline and wait for DOMContentLoaded.

<script defer src="/assets/jquery.min.js"></script>
<script defer src="/assets/slick.min.js"></script>

<!-- Variant 1, broken -->
<script>
  // Broken: runs immediately while parsing, before the deferred scripts
  $('.teaser-slider').slick();
</script>

<!-- Variant 2, fixed (use one variant, not both) -->
<script>
  // Fixed: DOMContentLoaded fires after all deferred scripts have run.
  // This holds only while the libraries use defer. With async, nothing
  // guarantees they have run by then.
  document.addEventListener('DOMContentLoaded', function () {
    $('.teaser-slider').slick();
  });
</script>

"DOMContentLoaded already fired": events that came too early

That phrase describes the symptom, it isn't a browser error. A script that loads late (async, tag manager, lazy load) registers its listener after parsing has finished, so the event is gone and the init code never runs. A defer script doesn't have this problem, because it runs before the event. Fix: check document.readyState, which is loading while the parser works and interactive or complete afterwards.

// Broken: never fires if this script runs after parsing has finished
document.addEventListener('DOMContentLoaded', init);

// Fixed: safe at any point in time
if (document.readyState === 'loading') {
  // { once: true } is optional, the event fires only once anyway
  document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
  init();
}

Consent and tracking

The consent manager loads asynchronously, and the analytics script asks for a consent state that isn't set yet. Depending on who wins, you track before consent or never track at all, so this shouldn't be left to load order. Fix: start analytics only on an explicit consent signal, and replay the current state for scripts that subscribe late.

// Broken: checks once, and never tracks if consent arrives a moment later
if (window.consentState?.analytics === true) {
  startTracking();
}

// Fixed. The state and event names are illustrative: use whatever
// your consent manager actually provides.
function onConsent(callback) {
  if (window.consentState?.analytics === true) {
    callback();          // consent already given, don't wait
  } else {
    window.addEventListener('consent:analytics', callback, { once: true });
  }
}

onConsent(startTracking);

Out-of-order responses

Search-as-you-type, filters and pagination share a pattern. Request A is slow, B is fast, B renders first, then A arrives and overwrites the newer result with a stale one. Fix: abort the previous request so only the latest response can render.

// Broken: a slow early response can overwrite a newer result
async function searchBroken(term) {
  const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`);
  render(await res.json());                  // render() is your own function
}

// Fixed version with multiple abort signals
let controller;

async function searchFixed(term) {
  controller?.abort();                       // cancel the previous request
  controller = new AbortController();

  // The try block also covers res.json(), so an abort while the
  // body is still being read is handled as well.
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(term)}`, {
      signal: controller.signal,
    });
    render(await res.json());
  } catch (err) {
    // Assumes the default abort reason. With abort(customReason),
    // the rejection carries that reason instead of an AbortError.
    if (err.name !== 'AbortError') throw err;
  }
}

How do you fix race conditions for good?

Make the outcome independent of who wins, instead of trying to win more reliably.

Timeline of the same three scripts on a slow connection: the init code waits for DOMContentLoaded, by which time the deferred library has run, so the arrival order no longer causes an error. AI generated
Same slow connection, same arrival order: the init code waits for DOMContentLoaded, so nobody has to win the race.
  1. Default to defer or type="module". Both keep document order, and modules are deferred by default. Reserve async for independent scripts that tolerate any arrival time, such as a self-contained embed. Analytics usually depends on consent or a data layer, so it rarely qualifies.
  2. Make dependencies explicit. Use module imports, promises or custom events instead of <script> order. Code that says "I need X" survives a reordered template.
  3. Write init code that is safe at any time and safe twice. Check document.readyState and guard against double initialization, because tag managers and extensions love to load things a second time (snippet below).
  4. Discard stale results. Use AbortController or a request counter, so only the latest response renders.
  5. Replay state, don't only announce it. An event fires once. A late subscriber has to read the current state (consent, config, "library ready") as well.
// initialization with a status update
function initSlider(el) {
  if (el.dataset.sliderReady) return;   // already initialized

  // ... set up the slider. If this throws, the flag below stays unset
  // and a later call can retry.

  el.dataset.sliderReady = '1';         // set only after a successful setup
}

document.querySelectorAll('.teaser-slider').forEach(initSlider);

How do you catch race conditions before visitors do?

You can't fix a race you can't reproduce, so make the slow runner slow on purpose.

  • Throttle network and CPU. The Network panel has presets such as Slow 4G (they vary by DevTools version). CPU throttling sits in the Performance panel's capture settings, and Lighthouse's mobile emulation uses 4x.
  • Test cold. Tick "Disable cache" (it applies while DevTools is open) and repeat in a private window.
  • Block or delay a request. Request blocking only blocks, which shows what happens if the consent manager never arrives. To delay instead, use throttling, a local proxy or a service worker.
  • Reload many times. A race that fails one load in twenty needs twenty loads, so loop it in an automated browser test.
  • Watch the console. Intermittent is not defined, is not a function and "cannot read properties of undefined" are the typical fingerprints.

If you'd like a second pair of eyes on your script loading or pagespeed optimization:

Fast but fragile is not fast

A site that scores 100 in Lighthouse but breaks for, say, one visitor in twenty on a slow connection pays later in support tickets, lost conversions and bugs nobody can reproduce. Code that declares what it needs survives a new loading strategy, a CMS update or a reordered template. Code that works only because script A loads before script B is waiting for a slower network.

If you want to see PageSpeed work in a real project, our write-up on the Dieter Schwarz Stiftung is a good starting point: PageSpeed optimization at the Dieter Schwarz Foundation

Markus Milkereit

Founder & Lead Developer

Writes about accessibility, maintainable front-ends and the unglamarous parts of running a website well.

Get in touch