Script Loading: async, defer & Execution Order

A <script src> tag with no attribute is the single most expensive thing you can put in a document head. It does not merely add a request: it suspends HTML tokenisation at the exact byte offset where it appears, and holds it there until the bytes arrive, compile and run to completion. Everything after that tag — the rest of the DOM, the LCP image element, the stylesheet that has not been discovered yet — waits.

The three attributes that change this (async, defer, and type="module") are widely known and almost as widely misapplied, because the interesting part is not what each one does in isolation. It is how the HTML parser, the preload scanner and the script scheduler divide the work between them, and which ordering guarantees survive that division. defer guarantees document order; async guarantees nothing; a dynamically injected script silently behaves like async even when you never typed the word; and an inline script with no src at all can still be parser-blocking if a stylesheet is outstanding.

This page works through the spec-level classification the HTML Standard actually performs, the engine-specific scheduling on top of it, a numbered procedure for assigning attributes across a real page, and the DevTools and PerformanceObserver workflow that proves the result. The worked example throughout is a single page with four external scripts on a 120 ms-RTT connection, and every number in every diagram comes from that one scenario.


How the parser, the scanner and the scheduler divide the work

Three subsystems touch a script element, and they run on different clocks.

The HTML parser is a strictly sequential tokeniser. It builds the DOM in document order and it is the only subsystem that can execute a parser-inserted script. When it reaches an external classic <script> with neither async nor defer, the spec has it set that element as the document’s pending parsing-blocking script and stop. Nothing further is appended to the DOM until the script has run.

The preload scanner is a second, speculative tokeniser that runs over buffered response bytes ahead of the main parser. It cannot build DOM and it cannot execute anything; all it does is recognise fetchable URLs — src, href, srcset, imagesrcset — and hand them to the fetch layer early. This is why a parser-blocking script is usually not as catastrophic as its description suggests: by the time the parser stalls on the tag, the scanner has typically had the request in flight for a hundred milliseconds or more. It is also why the scanner’s blind spots hurt so much. It never sees a URL that only exists after JavaScript runs, which is the entire subject of preload scanner misses in single-page apps.

The script scheduler owns the network side: which of the discovered URLs gets a socket first, at what fetch priority, and — in Chromium — whether the file is large enough to be compiled off the main thread while it is still downloading. The scheduler’s decisions are invisible in the markup and are the usual reason two scripts with identical attributes behave differently.

The diagram below traces one 90 KB parser-blocking script through all three lanes. The parser reaches the tag at 140 ms; the scanner found the URL at 40 ms and the fetch has been running since 60 ms; the bytes land at 420 ms and execution takes 100 ms.

One parser-blocking script across the parser, scanner and network lanes Three horizontal lanes over a 0 to 900 millisecond axis. The main parser lane parses for 140 ms, is stalled from 140 to 420 ms, executes the script from 420 to 520 ms, then parses to 640 ms where DOMContentLoaded fires. The preload scanner lane shows a scan window from 20 to 120 ms that discovers the script URL at 40 ms. The network lane shows the HTML document arriving first and the 90 KB script downloading from 60 to 420 ms, a fetch that started 100 ms before the parser needed it. One 90 KB parser-blocking script: 280 ms of dead parser time the network did not cause DOMContentLoaded 640 ms Main parser Preload scanner Network parse parser stalled run parse stall = 280 ms waiting for bytes + 100 ms compile and execute scan finds /app.js at 40 ms fetch starts 100 ms early HTML GET /app.js 90 KB, 360 ms 0 150 300 450 600 750 900 ms

Two conclusions follow from that picture, and they are the ones most often missed. First, the stall is not a network problem: the request was issued 80 ms before the parser arrived at the tag, and no amount of preconnect or priority tuning shortens the 280 ms the parser spends waiting for a 90 KB body on a slow link. Second, the last 100 ms of the stall is pure compile and execute — CPU work that scales with bundle size, not with bandwidth, and that no resource hint touches at all.

What the spec actually decides

The HTML Standard runs one algorithm — prepare the script element — every time a script element becomes ready to be processed, and its final step routes the element into exactly one of five destinations. In near-spec order:

  1. Classic script with src and defer, parser-inserted, without async → appended to the list of scripts that will execute when the document has finished parsing.
  2. Module script, parser-inserted, without async → appended to the same list. Deferred classic scripts and module scripts therefore interleave in document order; there is no separate module queue.
  3. Classic script with src, parser-inserted, with neither async nor defer → becomes the document’s pending parsing-blocking script. The parser stops.
  4. Classic script with no src, parser-inserted, at a moment when the document has a style sheet blocking scripts → also becomes the pending parsing-blocking script. An inline script with no network cost at all can block the parser on CSS.
  5. Anything with src whose force async flag is false → the list of scripts that will execute in order as soon as possible. Everything else with srcthe set of scripts that will execute as soon as possible, which is what async means.

The force async flag is the piece that catches people. Every script element created by document.createElement("script") starts with force async set to true, which is why an injected script behaves like async even though the markup never said so. The flag is cleared the moment the async content attribute is added or removed, which is why the idiom script.async = false — assigning to the IDL attribute — moves an injected script into the in-order list.

The in-order list: the class nobody writes by hand

Destination five in that list — the list of scripts that will execute in order as soon as possible — has no attribute spelling in HTML. You cannot reach it from markup at all. It exists solely for scripts inserted from JavaScript with force async cleared, and its semantics sit precisely between the two attributes everyone knows: fetches run in parallel, exactly as with async, but execution is serialised in insertion order, exactly as with defer. Unlike defer, it does not wait for parsing to finish; the first entry runs the moment its bytes are ready, even mid-parse.

That combination is what a script loader wants and almost never asks for. A loader that appends three elements without touching async gets three racing scripts; the same loader with one added line gets parallel downloads and a deterministic sequence. The difference costs nothing in network terms — the fetches are identical — and removes an entire class of intermittent failure.

The one behaviour to keep in mind is that the in-order list is global to the document, not per-loader. If two independent loaders both insert async = false scripts, their entries interleave in whatever order the insertions happened, and each loader’s chain is now gated on the other’s slowest file. Inserting a DocumentFragment in a single append, as in Step 5 below, keeps a chain contiguous.

Inline scripts: the parser-blocking class with no network cost

Destination four is the one that surprises experienced developers. An inline classic script — no src, no bytes to fetch — becomes the pending parsing-blocking script whenever the document has a style sheet blocking scripts at that moment. The reasoning is sound: an inline script may read computed style, and the CSSOM has to be complete for that answer to be correct. The consequence is that CSS blocks JavaScript, which blocks DOM construction:

<!-- This stylesheet takes 900 ms on a cold 4G connection. -->
<link rel="stylesheet" href="/css/app.css">

<!-- Scheduling rationale: this script costs nothing to fetch and ~0.2 ms to
     run, but it is parser-inserted, classic, and the stylesheet above is
     still outstanding — so the parser stops here for the full 900 ms.
     Moving it ABOVE the <link> removes the dependency entirely. -->
<script>
  document.documentElement.dataset.js = 'on';
</script>

Auditing for this is worth a pass of its own: grep every template for an inline <script> that appears after a <link rel="stylesheet"> in the head, and hoist the ones that do not read layout. The measured saving is the stylesheet’s remaining download time, which on a slow connection is routinely the largest single number on the page.


The five classes, side by side

Four external scripts, one document, three attribute strategies. The files are polyfill.js (8 KB, bytes complete at 92 ms), vendor.js (180 KB, complete at 545 ms), app.js (60 KB, complete at 300 ms) and analytics.js (14 KB, complete at 122 ms), declared in that document order. The parser would finish at 262 ms if nothing blocked it.

Execution slot and timestamp for four scripts under three loading strategies A four-row matrix. Rows are polyfill.js, vendor.js, app.js and analytics.js in document order. Columns show execution order under no attribute, under defer, and under async. The no-attribute and defer columns both keep document order; the async column reorders vendor.js to fourth and analytics.js to second, and app.js executes before vendor.js. Same four scripts, same network, three strategies: when each one actually executes document order no attribute parser-blocking defer after parsing, in order async on arrival 1. polyfill.js 8 KB, bytes at 92 ms runs 1st at 92 ms runs 1st at 262 ms runs 1st at 92 ms 2. vendor.js 180 KB, bytes at 545 ms runs 2nd at 545 ms runs 2nd at 545 ms runs 4th at 545 ms, last 3. app.js 60 KB, bytes at 300 ms runs 3rd at 664 ms runs 3rd at 664 ms runs 3rd at 300 ms, before vendor 4. analytics.js 14 KB, bytes at 122 ms runs 4th at 700 ms runs 4th at 700 ms runs 2nd at 124 ms Parser free at 712 ms with no attribute, 262 ms with defer — yet the tail script executes at 700 ms in both columns. async reorders: analytics.js jumps 4th to 2nd, vendor.js drops 2nd to 4th, and app.js now runs before the library it may depend on.

The matrix makes the honest trade explicit. defer does not make the slow file finish earlier — vendor.js still gates everything behind it at 545 ms, and analytics.js still runs at 700 ms. What defer buys is a parser that finishes at 262 ms instead of 712 ms, which is the number that moves First Contentful Paint and lets the render-blocking work complete. async genuinely gets each script running as early as physically possible, and pays for it with an execution order determined by the network. If app.js reads a global that vendor.js defines, the async column is a production incident waiting for a cache miss — the failure mode dissected in fixing async script race conditions.

There is a third reading of the matrix that is easy to miss. In the defer column, analytics.js — 14 KB, on disk since 122 ms — does not run until 700 ms, because it is queued behind 180 KB of vendor code it has nothing to do with. Head-of-line blocking inside the deferred list is real and it is the standard argument for taking genuinely independent third-party code out of that list and giving it async instead. The deferred list is a single-file queue: everything in it inherits the latency of the slowest member ahead of it.

Interleaving deferred classics and modules

Because destinations one and two write into the same list, a deferred classic script and a module script keep their relative document order. This is the only supported way to guarantee that a legacy global is installed before a module reads it:

<!-- Scheduling rationale: both entries land in the same "execute when the
     document has finished parsing" list, so document order is a hard
     guarantee across the classic/module boundary. Swapping these two lines
     changes the execution order; adding async to either one destroys it. -->
<script src="/js/legacy-globals.js" defer></script>
<script type="module" src="/js/app.js"></script>

There is one asymmetry worth internalising. A module’s own graph must resolve before the module executes, so a module entry with a deep import chain can hold the whole shared list open while it walks that chain — the deferred classic script declared after it waits for every transitive import to arrive. That is a queueing cost the markup does not show, and it is the reason a flat module graph matters more than the module count.


Spec and API reference

Attribute semantics

Attribute Applies to Effect Ordering guarantee
(none) external classic Parser-blocking at the tag position Document order, at parse position
defer external classic only Fetch in parallel, execute after parsing Document order, shared list with modules
async external classic, any module Fetch in parallel, execute on arrival None — arrival order
async + defer external classic async wins, defer ignored None
defer on inline classic Ignored entirely (no src) Runs at parse position
type="module" inline or external Implicitly deferred; graph fetched in parallel Document order with deferred classics
type="module" async inline or external Executes when the graph resolves None
defer on a module Ignored — modules are already deferred Unchanged
nomodule external classic Skipped by any engine that supports modules Unchanged
fetchpriority any script Moves the request within the scheduler queue Does not change execution semantics
blocking="render" any script Makes the script render-blocking without being parser-blocking Unchanged
script.async = false (IDL) injected script Clears force async, joins the in-order list Insertion order

Two rows deserve emphasis. fetchpriority changes when the bytes arrive, never when the code runs; raising a deferred script to high will not pull its execution ahead of the deferred script declared before it. And blocking="render" is the only attribute that decouples the two kinds of blocking: the parser keeps tokenising, but the first paint is held until the script has run — useful for a framework hydration entry point that must not paint an unstyled shell, and covered further under the fetchpriority attribute and priority hints.

Browser support

Feature Chromium (Chrome/Edge) Gecko (Firefox) WebKit (Safari)
async / defer on classic scripts Universal Universal Universal
type="module" (deferred by default) 61 60 10.1
async on a module script 61 60 11
nomodule fallback 61 60 11
Import maps 89 108 16.4
fetchpriority on <script> 101 132 17.2
blocking="render" on <script> 105 Not supported Not supported
renderBlockingStatus in Resource Timing 107 Not supported Not supported
Off-main-thread compile of large scripts Yes, from roughly 30 KB Yes Partial

Engine differences that change real timings

Behaviour Chromium WebKit Gecko
Network priority, parser-blocking script in <head> High; demoted to Medium once it appears after the first image in the document High Scheduled in the leader group with CSS
Network priority, async / defer Low Low Normal, but placed behind the leader group by class of service
Speculative pre-parsing while the parser is stalled Preload scanner over buffered bytes Preload scanner over buffered bytes Speculative parsing, discarded if document.write invalidates the tree
Compile while downloading Script streaming on a background thread past a size threshold Background parse for large scripts Off-main-thread parse for large scripts
document.write of a script from an injected script Blocked by intervention on slow connections Allowed Allowed, console warning
Deferred execution gated on pending stylesheets Yes Yes Yes

The priority rows explain a common surprise: in Chromium every async and defer script is fetched at Low priority, below the stylesheet, below the fonts and below the LCP image. That is usually correct — but if a deferred script is genuinely on the critical path, it will lose the connection to lower-value resources unless you promote it with fetchpriority="high", and on an HTTP/2 connection that difference in stream weighting is measurable.


Step-by-step: assigning attributes across a real page

Step 1 — Inventory the dependency graph before touching any tag

Attribute choice is a function of two facts per script: what it defines that others read, and whether it needs a parsed DOM. Write both down before editing markup. In a browser console on the current page:

// Scheduling rationale: the correct attribute depends on execution ORDER
// requirements, and document order is the only thing you can read off the
// markup. Dump the current classification so you can compare intent with
// what the parser is actually going to do.
[...document.querySelectorAll('script')].map((s, i) => ({
  i,
  src: s.src ? new URL(s.src).pathname : '(inline)',
  // An external classic script with neither attribute is the expensive case:
  // it is the document's pending parsing-blocking script at its tag position.
  klass: s.type === 'module'
    ? (s.async ? 'module async (arrival order)' : 'module (deferred, in order)')
    : !s.src ? 'inline (runs at parse position)'
    : s.async ? 'async (arrival order)'
    : s.defer ? 'defer (in order, after parse)'
    : 'PARSER-BLOCKING',
}));

Step 2 — Walk the decision in a fixed order

Three questions, asked in this sequence, resolve every script on a normal page. Asking them out of order is how async ends up on something that mutates the DOM.

Decision ladder for choosing between no attribute, defer and async Three question cards on the left, each with a yes arrow to an outcome card on the right and a no arrow down to the next question. Question one asks whether the script writes into the document during parsing or must run before first paint, answering yes means leaving it classic. Question two asks whether other scripts read its globals or it needs a parsed DOM, answering yes means defer. The fall-through case, a script independent of the DOM and of all other scripts, becomes async with a low fetch priority. Ask these three questions in this order — the first yes wins 1. Does it write into the document during parsing, or must it run before the first paint? Leave it classic, no attribute The parser stops until it has run. Budget the stall and measure it. 2. Do other scripts read the globals it defines, or does it need a fully parsed DOM at execution time? defer Fetched in parallel, executed in document order before DCL. 3. Neither: it is independent of the DOM and of every other script on the page. async fetchpriority=low Runs on arrival, order is not guaranteed, does not delay DCL. yes yes then no no

Step 3 — Write the markup the decision produced

<!-- Inline, tiny, no src: runs at its parse position. Scheduling rationale:
     defining the queue synchronously costs well under a millisecond and
     removes every ordering dependency on the async library below. -->
<script>
  window.metrics = window.metrics || [];
  window.track = function () { window.metrics.push(arguments); };
</script>

<!-- defer: order-critical pair. vendor.js defines the globals app.js reads,
     and the deferred list preserves document order, so this dependency is a
     guarantee rather than a race. Both execute before DOMContentLoaded. -->
<script src="/js/vendor.js" defer></script>
<script src="/js/app.js" defer></script>

<!-- async + low priority: no dependants, no DOM access on load, and the
     queue above means call sites never need it to have arrived yet.
     Low priority keeps it off the connection while the LCP image is fetching. -->
<script src="/js/analytics.js" async fetchpriority="low"></script>

<!-- No attribute, deliberately: this one sets a class on <html> to avoid a
     theme flash, so it MUST run before the first paint. Keep it inline-sized
     (< 1 KB) so the parser stall is compile time only, with no network cost. -->
<script src="/js/theme-boot.js"></script>

Step 4 — Prefer blocking="render" over a parser stall where it is available

If a script must complete before paint but does not need to interrupt DOM construction, the blocking attribute expresses exactly that, and the parser keeps working while the fetch is in flight:

<!-- Scheduling rationale: the parser continues tokenising the whole document
     (so the preload scanner keeps finding images and stylesheets), but paint
     is withheld until this has executed. Strictly better than a bare
     <script src> for a pre-paint boot script — in engines that support it.
     Engines that do not simply treat it as a normal async script, so the
     fallback is "may paint before it runs", not "breaks". -->
<script src="/js/theme-boot.js" async blocking="render"></script>

Step 5 — Force order on dynamically injected scripts

Injected scripts default to async semantics through the force async flag. When a third-party loader inserts a chain of files, that default is the bug:

// Scheduling rationale: createElement sets the force async flag, so these two
// would race. Assigning to the .async IDL attribute clears the flag and moves
// each element into "the list of scripts that will execute in order as soon as
// possible" — parallel fetches, sequential execution, insertion order kept.
function loadInOrder(urls) {
  const frag = document.createDocumentFragment();
  for (const url of urls) {
    const s = document.createElement('script');
    s.src = url;
    s.async = false;   // NOT the same as omitting it: this clears force async
    frag.appendChild(s);
  }
  // One insertion: the fragment's children enter the in-order list together,
  // so no other injected script can interleave between them.
  document.head.appendChild(frag);
}

loadInOrder(['/js/vendor.js', '/js/plugin.js', '/js/init.js']);

Step 6 — Give module graphs their dependency hints

A module script is deferred, but the preload scanner only sees the entry URL. Each nested import is discovered after its parent has been fetched and parsed, so a three-level graph costs three round trips before anything executes:

<!-- Scheduling rationale: modulepreload issues the fetch for a dependency the
     scanner cannot see, flattening the discovery chain to a single round trip.
     Hint the entry plus every module in the first-execution graph; anything
     beyond that competes with the LCP image for bandwidth. -->
<link rel="modulepreload" href="/js/entry.8f21c0.js">
<link rel="modulepreload" href="/js/state.51d7f2.js">
<script type="module" src="/js/entry.8f21c0.js"></script>

<!-- async on a module: same arrival-order semantics as a classic async script.
     Correct only because this widget shares nothing with the entry graph. -->
<script type="module" src="/js/widget.js" async></script>

The chunk-graph side of this is covered in modulepreload and ES module loading.

Step 7 — Freeze the classification in a test

Attribute regressions are silent: nothing throws, nothing logs, the page just gets slower. A template edit that drops defer from one tag costs several hundred milliseconds and will survive code review indefinitely. Assert the classification instead:

// Scheduling rationale: the classification, not the attribute string, is what
// costs time — so assert the CLASS. This fails the build if a template edit
// reintroduces a parser-blocking script, or flips an order-critical pair to
// async, both of which are invisible in a rendered-output diff.
const ALLOWED_BLOCKING = new Set(['/js/theme-boot.js']);

function classify(el) {
  if (!el.src) return 'inline';
  if (el.type === 'module') return el.async ? 'module-async' : 'module';
  if (el.async) return 'async';
  if (el.defer) return 'defer';
  return 'blocking';
}

test('no unexpected parser-blocking scripts, and the boot pair stays ordered', async ({ page }) => {
  await page.goto('/');
  const tags = await page.$$eval('script', (els) =>
    els.map((el) => ({ src: el.getAttribute('src'), cls: classify(el) })));

  for (const t of tags.filter((t) => t.cls === 'blocking')) {
    expect(ALLOWED_BLOCKING.has(t.src)).toBe(true);
  }
  // Order-critical pair must both be deferred: one async and the guarantee is gone.
  const order = tags.filter((t) => t.src?.includes('vendor') || t.src?.includes('app'));
  expect(order.map((t) => t.cls)).toEqual(['defer', 'defer']);
});

Verification workflow

Reading the Network panel

Open DevTools, disable cache, throttle to Slow 4G, reload, and add the Priority column (right-click any column header). The picture you are checking for is below: one stylesheet and one image at high priority, and every script sitting at Low behind them.

Network panel view of a correctly scheduled page, with the Priority column exposed A drawn DevTools Network panel with columns for name, type, priority, start time and waterfall. styles.css is Highest, hero.avif is High, and app.js, vendor.js and analytics.js are all Low. The DOMContentLoaded marker sits at 0.55 seconds, and vendor.js is still downloading past it. Network panel, Priority column exposed: what correct script scheduling looks like Name Type Priority Start Waterfall DCL styles.css stylesheet Highest 0.10 s hero.avif img High 0.11 s app.js script, defer Low 0.11 s vendor.js script, defer Low 0.12 s analytics.js script, async Low 0.13 s 0 0.4 s 0.8 s 1.2 s Priority column: defer and async scripts are Low in Chromium so they yield the connection to the stylesheet and the LCP image. vendor.js is still on the wire at DCL

Three things to check in that panel, in order. Any script showing a priority of High is parser-blocking — confirm that was intentional. Any script whose bar begins noticeably later than its start time has been queued by the scheduler behind higher-priority work, which is a bandwidth contention story rather than an attribute story; the network waterfall timing breakdown shows how to separate the two. And any deferred script whose bar extends past the DCL marker is the file gating your interactivity.

Measuring it in the field

DevTools shows you one load on one machine. Resource Timing plus Long Tasks give you the same split — bytes versus CPU — across the whole population:

// Scheduling rationale: a script whose cost is (responseEnd - requestStart) is
// a NETWORK problem — raise its priority, shrink it, or move it earlier. One
// whose cost shows up as a long task right after responseEnd is a COMPILE AND
// EXECUTE problem, which no resource hint can fix; that one needs splitting.
const scripts = new Map();

new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.initiatorType !== 'script') continue;
    scripts.set(e.name, {
      // Chromium only: 'blocking' means the parser stopped for this file.
      blocking: e.renderBlockingStatus,
      // Time spent in the scheduler queue before the request left the socket:
      // large values mean it lost priority contention, not that the server is slow.
      queued: Math.round(e.requestStart - e.startTime),
      wire: Math.round(e.responseEnd - e.requestStart),
      bytes: e.encodedBodySize,
    });
  }
}).observe({ type: 'resource', buffered: true });

// Long tasks after responseEnd are the compile-and-execute half of the cost.
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.duration >= 50) {
      navigator.sendBeacon('/rum/script-cost', JSON.stringify({
        start: Math.round(e.startTime),
        duration: Math.round(e.duration),
        // Correlate by time: attribution rarely names a classic script directly.
        candidates: [...scripts].filter(([, v]) => v.wire > 0).length,
      }));
    }
  }
}).observe({ type: 'longtask', buffered: true });

To verify execution order specifically — the thing async does not guarantee — emit one mark from the first line of every bundle and read them back after load:

// First statement in each bundle. Scheduling rationale: document order is what
// you wrote; mark order is what the scheduler did. Comparing the two is the
// only reliable way to catch an async ordering assumption before users do.
performance.mark('exec:vendor');

// ...then, from a DOMContentLoaded or load handler:
addEventListener('load', () => {
  const order = performance.getEntriesByType('mark')
    .filter((m) => m.name.startsWith('exec:'))
    .sort((a, b) => a.startTime - b.startTime)
    .map((m) => `${m.name.slice(5)}@${Math.round(m.startTime)}ms`);
  console.table(order);   // expected: vendor before app, every single load
});

Run that on a cold cache, a warm cache and a throttled connection. If the order changes between the three, at least one script is async when it should be defer.

What the numbers should move

Attribute changes have a characteristic signature in the metrics, and knowing it tells you quickly whether a change landed or whether something else is dominating. Using the four-script page from the matrix above, throttled to Slow 4G:

Metric Four parser-blocking scripts Two defer + one async + one inline Why
Parser finished 712 ms 262 ms Tokenisation never stops for a fetch
First Contentful Paint 760 ms 310 ms Paint follows the parser, once CSSOM is ready
DOMContentLoaded 740 ms 712 ms Still gated on the deferred list’s slowest member
Last script executed 700 ms 700 ms Unchanged — defer does not shrink vendor.js
load event 780 ms 745 ms The async script still delays it
Total blocking time Unchanged Unchanged Compile and execute cost is attribute-independent

The two rows that stay flat are the useful diagnostic. If moving everything to defer also moved your Total Blocking Time, something other than the attribute changed — most often a bundler swapping a chunking strategy at the same time. And if DOMContentLoaded barely improved, the deferred list is being held open by one slow file, which is a splitting problem rather than a scheduling one.


Edge cases and gotchas

A pending stylesheet delays deferred execution. The spec requires that a parser-inserted classic script not execute while the document has a style sheet blocking scripts, and the “finish parsing” algorithm applies the same wait to the deferred list. A 900 ms stylesheet therefore pushes DOMContentLoaded out by 900 ms even though every script’s bytes arrived in the first 200 ms. If your deferred scripts appear to execute far later than the waterfall suggests they should, look at the stylesheet, not the scripts.

defer on an inline script does nothing. The deferral branch of the algorithm requires a src attribute. An inline <script defer> executes at its parse position, exactly as if the attribute were absent — a silent no-op that shows up in a surprising number of production templates.

async and defer together resolve to async. The classification checks the async attribute first, so the common “belt and braces” pattern <script src="..." async defer> gives you arrival-order execution. It is only a useful pattern if you actually want async with a fallback for engines so old that they support defer but not async, which no longer describes anything in the field.

document.write from a non-parser-inserted script destroys the document. Once the parser has finished, the insertion point is undefined and document.write implies document.open(), wiping everything already rendered. From an async script mid-parse it is nearly as bad, which is why Chromium ships an intervention that refuses to execute a parser-blocking script injected via document.write from a third-party origin on slow connections. Legacy tag loaders that build their pipeline out of document.write are the main casualty, and the workaround is covered in deferring third-party scripts without breaking analytics.

async does not delay DOMContentLoaded, but it does delay load. Every external script sets the “delaying the load event” flag while it is fetching and executing. So an async third-party script that takes four seconds will not move DOMContentLoaded at all, and will move the load event by four seconds — which matters if anything in your stack, including a third-party tag, keys off load.

The preload scanner cannot see inside <template>, <noscript> or a string. Markup that only becomes real after JavaScript runs is invisible to the scanner, so scripts inside it start their fetch after execution rather than in parallel with it. The same applies to a script URL assembled from configuration at runtime: the scanner sees no URL to fetch, so the request begins one full round trip later.

Injected scripts inherit async, and so do their children. A loader that injects a script which in turn injects three more produces a four-level serial chain, each level costing a fetch plus a parse. Flattening it with a single in-order insertion (Step 5) or a set of modulepreload hints usually removes two full round trips.

Module scripts fetch with CORS semantics, always. A <script type="module" src> pointing at another origin requires valid CORS response headers even without a crossorigin attribute, unlike a classic script. A cross-origin module that works locally and 404s the CORS check in production fails silently: no execution, no parser error, just a console message.

Bytecode caching changes second-load numbers dramatically. Chromium caches compiled bytecode for scripts above a size threshold after they have been executed a few times, so a script that costs 140 ms of compile on the first visit may cost 20 ms on the fourth. Always compare cold-cache runs against cold-cache runs, or a genuine regression will hide behind a warm code cache. This interacts with your cache and revalidation policy: changing a script’s URL on every deploy also throws away its bytecode cache entry.

Third-party scripts change class between versions. A vendor snippet delivered as async today may ship a version that injects a parser-blocking child tomorrow. Pin the behaviour in a test rather than in a code review, and track the aggregate through third-party resource impact mapping.

A script element moved in the DOM does not re-run. Every script element carries an “already started” flag, set when it first begins processing. Re-appending it elsewhere, cloning it with cloneNode, or moving it between documents will not execute it a second time — which is why a framework that re-renders a fragment containing a <script> from innerHTML silently drops it. innerHTML never marks scripts as ready to run at all; only a real element insertion does.

type="module" implies strict mode and a fresh scope. Converting a classic script to a module to get free deferral changes its semantics: top-level this is undefined rather than window, declarations no longer land on the global object, and the file is evaluated exactly once even if imported from three places. A “just add type=module” change to a legacy bundle that assigns implicit globals will fail at execution time, not at parse time, and often only on the second page in a flow.

An async module still waits for its whole graph. async on a module means “execute as soon as the graph is ready”, not “execute as soon as this file arrives”. A three-deep import chain under an async module resolves serially, so the script can execute later than a defer sibling despite the attribute suggesting the opposite. Measure before assuming async is the earlier option for module code.

Duplicate src values are deduplicated by the fetch layer, not by the script layer. Two identical <script src> tags produce one network request and two executions. If the file is not idempotent — it appends a DOM node, it increments a counter, it registers a listener — you get the side effect twice while the waterfall shows a single clean request, making the cause almost invisible in the Network panel.

fetchpriority="low" on an async script is not the same as removing it. A low-priority async script still occupies a connection slot, still runs main-thread work on arrival, and still delays the load event. Demoting priority is a bandwidth-contention fix, not a CPU fix; if the problem is a 300 ms long task, the answer is a facade or a later trigger, not a priority attribute.


FAQ

Q: Is defer always safer than async?

For anything with a dependency graph, yes. defer preserves document order and guarantees a fully parsed DOM at execution time. async offers no ordering guarantee at all: execution order equals arrival order, so a cache hit on one file and a cold fetch on another can invert the sequence between two loads of the same page on the same device. Use async only where you can state, in one sentence, why the script has no dependants and no dependencies.

Q: Does defer make the last script finish sooner?

Usually not. defer changes when the parser is free, not when a slow file arrives. In the worked example above the tail script executes at 700 ms with and without defer; what changes is that the parser finishes at 262 ms instead of 712 ms, which is what moves First Contentful Paint and lets the preload scanner reach the rest of the document. If you need the tail script itself to run sooner, the fix is a smaller bundle or a higher fetch priority, not an attribute.

Q: Do I still need defer on a type="module" script?

No. A module script with no async attribute is already appended to the list that executes after parsing, and it shares that list with deferred classic scripts, so relative document order between the two is preserved. The defer attribute on a module is ignored by every engine. It is harmless, but it is not doing anything.

Q: Why did my async script run before the DOM element it needed existed?

Because async executes as soon as the bytes are ready, which is frequently in the middle of parsing. If the script queries an element declared further down the document, querySelector returns null. Either switch it to defer, or register the DOM work in a DOMContentLoaded listener at the top of the async script — the listener is cheap, is registered before the event fires, and makes the timing explicit rather than accidental.

Q: Can fetchpriority="high" make a deferred script run earlier?

It can make its bytes arrive earlier, and no further. Execution position within the deferred list is fixed by document order, so promoting the third deferred script does not let it jump the two ahead of it — it just guarantees it is not the one everyone else is waiting for. Promoting the first deferred script, on the other hand, is often the single highest-value change on a page whose interactivity is gated by a large framework bundle.