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>
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:
-
Parser encounters
<script>
→ Pauses HTML parsing - Fetches the script → Network request (could be slow!)
- Executes immediately → Runs in order encountered
- 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>
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>
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>
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
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>
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();
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();
});
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();
});
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);
});
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
};
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:
- Open DevTools
- Network tab → Block request domain
- Add CDN domains to blocklist
- 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);
};
}
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
- Script execution order is not guaranteed unless you explicitly control it
- Async and defer change loading behavior in ways that can introduce race conditions
- Network conditions in production are wildly different from development
- CDN dependencies add points of failure you don't control
- Defensive coding and explicit dependency management prevent most issues
- Test with realistic network conditions to catch problems early
- 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)