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.

A parser-blocking classic script The parser stops at a classic script, waits for the network fetch and script execution, and only then resumes tokenizing the body. Main thread (time β†’) Parse head parser STOPPED Execute analytics.js Parse body (late) Network fetch analytics.js First paint cannot happen until the body tokenizes β€” after the script returns

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.

Classic, defer, and async scheduling compared A three-row comparison showing how classic scripts block parsing, defer scripts run in order after parsing, and async scripts run as soon as each fetch resolves. classic parse STOP + execute parse resumes blocks + in order defer parse (uninterrupted) execute in order before DOMContentLoaded fetch (parallel, early) async parse execute now parse resumes on fetch, any order fetch resolves β†’ Both keep the tokenizer running; only execution timing and ordering differ

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.

First paint before and after deferring the script Two stacked timelines showing first contentful paint blocked behind script execution in the classic case and moved earlier once the script is deferred. classic parse execute analytics.js parse FCP 48ms defer parse (uninterrupted) FCP 13ms fetch analytics.js (parallel) execute (after FCP) Same fetch, same execution cost β€” moved off the critical path FCP advances ~35ms because paint no longer waits on the 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.

Decision tree for choosing defer, async, or blocking A decision tree routing a script to blocking, defer, or async based on whether it is needed for first paint, touches the DOM, or is fully independent. External script to load how should it schedule? Needed for first paint? (hydrates visible content) yes no Keep blocking / inline optimize its own cost DOM or sibling dependency? order matters? yes no defer async

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.