Deferring Non-Critical Scripts with defer and async
A classic <script src> with no attribute in the <head> freezes HTML tokenization the instant the parser reaches it β the tokenizer stops emitting DOM nodes, waits for the network to deliver the script, runs it to completion, and only then resumes, so First Contentful Paint slips by the full download-plus-execute time of a file that never needed to run before paint. This guide is part of Preload Scanner and Resource Loading, itself one stage of Browser Rendering Pipeline Fundamentals, and it narrows in on one lever: the defer and async attributes that move a scriptβs fetch and execution off the parserβs critical path without touching the scriptβs contents.
The Parser-Blocking Default
The smallest page that reproduces the stall puts one external script in the head and one visible paragraph after it. The paragraph cannot tokenize until the script has finished executing, so nothing paints while the network is in flight.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- BAD LINE: no defer/async β parser stops here until analytics.js runs -->
<script src="/vendor/analytics.js"></script>
</head>
<body>
<h1>Checkout</h1>
<p>This paragraph is blocked behind a script it never depends on.</p>
</body>
</html>
The script tag is parser-blocking by specification: encountering it puts the HTML parser into a state where it must run the script synchronously before continuing tree construction. Because analytics.js is an ordinary classic script with no attribute, the browser fetches it (a network round-trip if it is not warm in cache), hands it to the JavaScript engine, executes every top-level statement, and only when the engine returns does the tokenizer read the <h1> and <p> that follow. On a cold connection that is easily one to two frame budgets of pure main-thread idle while the render tree is empty. The point of defer and async is that a script whose result is not needed to build first paint should not be allowed to hold the parser hostage.
How defer and async Change Scheduling
Both attributes apply only to external scripts (src present), and both let the tokenizer keep running while the file is fetched β the difference is when the engine is allowed to execute the downloaded code. The browser tracks pending scripts in two internal lists that the HTML spec names explicitly. defer scripts go on the list of scripts that will execute when the document has finished parsing, an ordered queue drained in document order immediately before the DOMContentLoaded event fires. async scripts go on the set of scripts that will execute as soon as possible β an unordered set, so each one runs the moment its own fetch resolves, in whatever order the network happens to deliver them, interrupting parsing at that instant if it is still going.
That single scheduling difference produces the entire behavioral split. A defer script is guaranteed to run after the DOM is fully built and in source order, which makes it safe for code that queries elements or depends on a sibling script. An async script offers no ordering and no DOM guarantee, which makes it right only for self-contained code β a beacon, an isolated analytics ping β that touches neither the DOM it may race nor another script. Critically for load performance, both attributes are visible to the preload scanner, so the fetch is discovered and kicked off early even though execution is postponed.
The trade-offs collapse into a small table. The pipeline phase in each row is HTML parsing / document construction, and the cost is measured in main-thread stall against the frame budget.
| Attribute | Execution timing | Ordering guarantee | DOM available | Blocks parsing |
|---|---|---|---|---|
| none (classic) | Immediately, synchronously | Source order | Not guaranteed | Yes β full fetch + execute |
defer |
After parse, before DOMContentLoaded |
Source order | Yes, fully built | No |
async |
As soon as fetch resolves | None (network order) | Not guaranteed | Only briefly, at execute time |
Reading the Difference in a Trace
In a Chrome DevTools Performance recording the win is visible as the disappearance of the Evaluate Script slice wedged inside the Parse HTML region. With the classic tag, tokenization splits into a before-slice and an after-slice with script execution jammed between them; first paint waits behind all three. With defer, Parse HTML runs as one uninterrupted slice, the fetch happens in parallel, and the Evaluate Script slice moves to the tail of parsing where it no longer gates paint.
ββ CLASSIC ββββββββββββββββββββββββββββββββββββββββββββ
[Main Thread] β navigation @ 0ms
ββ ParseHTML 0.0ms β 4.1ms β stops at <script>
ββ EvaluateScript analytics.js 4.1ms β 39.7ms β 35.6ms, parser idle
ββ ParseHTML (resume) 39.7ms β 46.0ms β body finally tokenizes
ββ First Contentful Paint 48.3ms β blocked the whole time
ββ DEFER ββββββββββββββββββββββββββββββββββββββββββββββ
[Main Thread] β navigation @ 0ms
ββ ParseHTML 0.0ms β 10.4ms β one uninterrupted slice
β (Network: fetch analytics.js 1.2ms β 33.5ms β parallel, scanner-found)
ββ First Contentful Paint 12.9ms β paints ~35ms sooner
ββ EvaluateScript analytics.js 33.6ms β 39.1ms β runs after paint
ββ DOMContentLoaded 39.2ms
Two numbers tell the story. In the classic trace, FCP lands at 48.3ms because the 35.6ms execution slice sits directly on the critical path. In the defer trace, the identical fetch overlaps parsing β its bytes are pulled while the tokenizer keeps working β and FCP moves up to 12.9ms, with the scriptβs execution deferred to 33.6ms, safely after the paint. The script did exactly the same work; only its scheduling changed. Because the render tree is no longer waiting on script execution, the downstream CSSOM construction and layout stages start on time instead of stacking behind the JavaScript engine.
Choosing and Applying the Fix
The fix is a single attribute, but picking the right one depends on what the script does. Use defer when the code touches the DOM or depends on another scriptβs globals β it runs after the DOM is complete and preserves source order, so a library and the code that uses it stay correctly sequenced. Use async only for fully independent scripts that neither read the DOM nor coordinate with siblings, because it makes no ordering promise. When in doubt, defer is the safer default for anything in the document body of an app.
The before/after below is a complete, runnable pair. The βbeforeβ blocks the parser on three head scripts; the βafterβ defers the ordered application code and marks the isolated beacon async.
<!-- BEFORE: three parser-blocking scripts gate first paint -->
<head>
<script src="/vendor/framework.js"></script> <!-- blocks parse -->
<script src="/app/main.js"></script> <!-- blocks parse, needs framework -->
<script src="/vendor/beacon.js"></script> <!-- blocks parse, independent -->
</head>
<!-- AFTER: parser runs to completion; scripts execute off the critical path -->
<head>
<!-- defer preserves order: framework.js runs before main.js, both after DOM builds -->
<script src="/vendor/framework.js" defer></script>
<script src="/app/main.js" defer></script>
<!-- async: self-contained beacon, no DOM or ordering dependency -->
<script src="/vendor/beacon.js" async></script>
</head>
Two gotchas survive the rename. First, an inline script (no src) ignores both attributes β defer and async are no-ops on it, so inline code between deferred scripts can still run early and break assumed ordering; move such logic into a deferred external file or an event handler. Second, a defer script must not call document.write, which throws once the document has finished parsing. If a script genuinely must run before first paint β a framework that hydrates above-the-fold content β deferring it is the wrong call, and the render-blocking-versus-deferred decision belongs to Critical Rendering Path Optimization and its companion guide on eliminating render-blocking CSS and JS.
Verification Checklist
After adding defer or async to the non-critical scripts, re-record a trace and confirm each item:
Frequently Asked Questions
What is the practical difference between defer and async?
Both let the parser keep tokenizing while the script downloads. defer runs its scripts after the document is fully parsed, in source order, right before DOMContentLoaded β safe for DOM-dependent or interdependent code. async runs each script the moment its own fetch resolves, in no guaranteed order, so it fits only self-contained scripts that touch neither the DOM nor another script.
Do defer and async work on inline scripts?
No. Both attributes require an external src; on an inline <script> with no src they are ignored and the script runs synchronously at its position, blocking the parser. If you need to postpone inline logic, move it into an external deferred file or register it inside a DOMContentLoaded handler so it runs after the DOM is built.
Will deferring a script hurt Interaction to Next Paint or other metrics?
Deferring generally helps First Contentful Paint and Largest Contentful Paint by clearing the parserβs critical path, but it moves execution to the end of parsing where many deferred scripts can coalesce into a long task and delay interactivity. Watch that the freed-up window does not create one large post-parse execution block; split heavy work or lazy-load it so INP stays healthy.
Why does the preload scanner still fetch a deferred script early?
The preload scanner reads the raw HTML bytes ahead of the main tokenizer and starts fetches for any <script src> it finds, regardless of defer or async. The attribute changes only when the engine is allowed to execute the code, not when the fetch begins β so a deferred script downloads in parallel with parsing and is ready to run the instant parsing completes.
Should I use defer or type=module for modern app code?
A type="module" script is deferred by default β it never blocks the parser and executes after parsing in order β so for module-based app code you often need no extra attribute. Add async to a module only when it is fully independent and you want it to run as soon as it loads. Plain classic scripts still need explicit defer to get the same non-blocking behavior.
Related Guides
- How the preload scanner speculatively loads resources β why deferred fetches still start early.
- Preload Scanner and Resource Loading β the resource-loading stage this scheduling lever belongs to.
- Eliminating Render-Blocking CSS and JS β the broader critical-path decisions once scripts are non-blocking.
- How document.write blocks the HTML parser β a related parser re-entrancy that defer cannot fix.