DEV Community

Todd H. Gardner for TrackJS

Posted on

Script Loading Race Conditions

You've tested everything locally. Your staging environment is flawless. Then you deploy to production and suddenly users are reporting "undefined is not a function" errors.

These intermittent failures often come down to script loading race conditions, timing issues that only surface under real, world network conditions. Let's dive into how browsers actually load and execute JavaScript, and why your code might be running before its dependencies are ready.

The Classic Case: jQuery's $ is not defined

One of the most common manifestations of script loading race conditions is the infamous $ is not defined error. While working with the team at TrackJS on documenting JavaScript errors, we found this error affects thousands of production applications daily, particularly those loading libraries from CDNs.

Here's what typically happens:

<script src="https://cdn.example.com/jquery.min.js" async></script>
<script>
  // This might run BEFORE jQuery loads!
  $(document).ready(function() {
    console.log('Ready!');
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Even though the scripts appear in order, there's no guarantee the first script will finish loading before the second executes. This is especially true when the first script comes from an external domain.

How Browsers Actually Load Scripts

Understanding the browser's script loading pipeline is crucial to preventing these race conditions. Here's what really happens when the browser encounters a <script> tag:

The Default (Blocking) Behavior

Without any attributes, scripts block HTML parsing:

  1. Parser encounters <script> → Pauses HTML parsing
  2. Fetches the script → Network request (could be slow!)
  3. Executes immediately → Runs in order encountered
  4. Resumes HTML parsing → Continues building the DOM

This seems safe, but it's terrible for performance. Modern developers often try to optimize this with async or defer attributes, which is where race conditions creep in.

The Async Attribute: Chaos Mode

<script async src="library.js"></script>
<script async src="app.js"></script>
Enter fullscreen mode Exit fullscreen mode

With async:

  • Scripts download in parallel with HTML parsing
  • Execute immediately when downloaded
  • No execution order guarantee
  • Could run before, during, or after DOMContentLoaded

This means app.js might execute before library.js, even if library.js appears first in the HTML. Chaos!

The Defer Attribute: Ordered but Delayed

<script defer src="library.js"></script>
<script defer src="app.js"></script>
Enter fullscreen mode Exit fullscreen mode

With defer:

  • Scripts download in parallel with HTML parsing
  • Wait to execute until HTML parsing completes
  • Execute in order they appear in the document
  • Always run before DOMContentLoaded event

This sounds perfect, but there's a catch: defer only works for external scripts with a src attribute. Inline scripts ignore defer completely.

Real-World Race Condition Scenarios

Scenario 1: Mixed Loading Strategies

<script defer src="jquery.js"></script>
<script>
  // This inline script runs IMMEDIATELY, before deferred jQuery!
  $(function() {
    console.log('Broken!');
  });
</script>
Enter fullscreen mode Exit fullscreen mode

The inline script executes immediately while jQuery waits due to defer. Result: $ is not defined.

Scenario 2: Dynamic Script Injection

// Dynamically injected scripts are async by default
const script = document.createElement('script');
script.src = 'dependency.js';
document.head.appendChild(script);

// This runs immediately, not waiting for dependency.js
initializeApp(); // Error if this needs dependency.js
Enter fullscreen mode Exit fullscreen mode

Scenario 3: Network Timing Variations

<script src="https://fast-cdn.com/small-lib.js"></script>
<script src="https://slow-cdn.com/huge-lib.js"></script>
<script src="/js/app.js"></script>
Enter fullscreen mode Exit fullscreen mode

In development (fast network): Everything loads in order.
In production (variable network):

  • User on fast connection: Works fine
  • User on 3G: small-lib loads, huge-lib stalls, app.js breaks
  • User behind corporate firewall: CDNs blocked entirely

How to Prevent Script Loading Race Conditions

1. Explicit Load Event Handling

Instead of hoping scripts load in order, explicitly wait for them:

function loadScript(src) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// Guaranteed order
async function initializeApp() {
  try {
    await loadScript('https://cdn.example.com/jquery.js');
    await loadScript('https://cdn.example.com/plugins.js');
    await loadScript('/js/app.js');

    // Everything is loaded!
    $(document).ready(() => {
      console.log('Safe to use jQuery');
    });
  } catch (error) {
    console.error('Script loading failed:', error);
    // Handle gracefully
  }
}

initializeApp();
Enter fullscreen mode Exit fullscreen mode

2. Module Systems (ES6 Modules)

Modern JavaScript modules handle dependencies explicitly:

// app.js
import $ from 'jquery';
import { initPlugin } from './plugin.js';

// These imports are guaranteed to resolve before this code runs
$(document).ready(() => {
  initPlugin();
});
Enter fullscreen mode Exit fullscreen mode

With native ES6 modules or bundlers like Webpack, dependencies are explicit and loading order is guaranteed.

3. Script Loading Libraries

Libraries like LoadJS or HeadJS provide robust script loading with dependency management:

loadjs(['jquery.js', 'plugins.js'], 'core');

loadjs.ready('core', function() {
  // All core scripts are loaded
  initializeApp();
});
Enter fullscreen mode Exit fullscreen mode

4. Defensive Coding Patterns

Always check for dependencies before using them:

// Retry pattern for external dependencies
function waitForGlobal(name, callback, maxAttempts = 50) {
  let attempts = 0;

  function check() {
    attempts++;

    if (window[name]) {
      callback();
    } else if (attempts < maxAttempts) {
      setTimeout(check, 100);
    } else {
      console.error(`${name} failed to load after ${maxAttempts} attempts`);
    }
  }

  check();
}

// Usage
waitForGlobal('jQuery', function() {
  // Safe to use jQuery
  $(document).ready(initApp);
});
Enter fullscreen mode Exit fullscreen mode

5. Bundle Everything

The most reliable solution? Bundle all your JavaScript together:

// webpack.config.js
module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'bundle.js'
  },
  // All dependencies bundled in correct order
};
Enter fullscreen mode Exit fullscreen mode

One file, no external dependencies, no race conditions. The tradeoff is bundle size and caching granularity.

Testing for Race Conditions

Race conditions are notorious for working fine in development but breaking in production. Here's how to catch them early:

1. Throttle Your Network

Chrome DevTools → Network tab → Throttling:

  • Test with "Slow 3G" preset
  • Create custom profiles matching your users' connections
  • Add variable latency to simulate real networks

2. Block External Resources

Test what happens when CDNs fail:

  1. Open DevTools
  2. Network tab → Block request domain
  3. Add CDN domains to blocklist
  4. Reload and observe failures

3. Randomize Script Loading

Add artificial delays to expose race conditions:

// In development only!
if (process.env.NODE_ENV === 'development') {
  const originalAppendChild = Node.prototype.appendChild;

  Node.prototype.appendChild = function(child) {
    if (child.tagName === 'SCRIPT' && Math.random() > 0.5) {
      // Random delay between 0-2 seconds
      setTimeout(() => {
        originalAppendChild.call(this, child);
      }, Math.random() * 2000);
      return child;
    }
    return originalAppendChild.call(this, child);
  };
}
Enter fullscreen mode Exit fullscreen mode

4. Use Error Monitoring

Production race conditions often only appear under specific conditions. Use error monitoring to catch them:

  • Track script loading errors
  • Monitor undefined function/variable errors
  • Correlate errors with network conditions
  • Identify patterns (specific browsers, connection speeds, regions)

Key Takeaways

  1. Script execution order is not guaranteed unless you explicitly control it
  2. Async and defer change loading behavior in ways that can introduce race conditions
  3. Network conditions in production are wildly different from development
  4. CDN dependencies add points of failure you don't control
  5. Defensive coding and explicit dependency management prevent most issues
  6. Test with realistic network conditions to catch problems early
  7. Monitor production errors to catch edge cases you didn't anticipate

Script loading race conditions are one of those problems that seem simple but hide tremendous complexity. The key is understanding how browsers actually work, not how we think they work. Once you grasp the loading pipeline, you can write JavaScript that's resilient to the chaos of real-world networks.

Remember: every external script is a potential race condition. Every inline script is a potential execution order issue. Plan accordingly, and your production JavaScript will be far more reliable.


Have you encountered bizarre script loading issues in production? What patterns do you use to prevent race conditions? Share your war stories in the comments!

Top comments (0)