Free lesson · Frontend System Design
Why does the order of your tags change your load time?
Here's a fact that feels like it shouldn't be true until you understand the machinery: moving a single <script> tag from the top of your <head> to the bottom of your <body> can make a page feel dramatically faster, with zero change to the code itself. Same bytes, same logic, completely different experience.
If that surprises you, you're in good company - it surprised me for an embarrassingly long time, back when I was learning from tutorials that said "put scripts at the bottom" without ever explaining why. The "why" is entirely about how the browser parses HTML and CSS, and how those two parsers interact with your scripts. Once you see it, a whole category of performance advice stops being folklore you memorize and becomes something you can derive on the spot in an interview. So let's open up the parser.
HTML parsing: incremental, and interruptible
The HTML parser does two jobs: tokenize the character stream into tags and text, and build the DOM tree from those tokens. The critical property is that it's incremental - it processes the document top to bottom as bytes arrive, constructing the DOM as it goes. This is good news: a browser can start building and even painting the top of a page before the bottom has downloaded.
But the HTML parser has a vulnerability: it can be paused. And the thing that pauses it is the thing you'd least expect to be a problem - your own <script> tags.
Scripts block the parser
When the HTML parser encounters a plain <script src="..."> tag, it stops. It must stop, for a reason that's logical once you see it: a script can call document.write() or otherwise modify the DOM that's being built right now. The parser can't safely continue constructing a tree that a script might rewrite mid-construction. So it halts parsing, downloads the script (if external), executes it fully, and only then resumes building the DOM.
This is why a <script> in your <head> is so costly: the browser hits it early, freezes DOM construction, and goes off to fetch and run JavaScript while the rest of your page sits unparsed and unpainted. The user stares at a blank screen. That, precisely, is why the old advice was "put scripts at the bottom of <body>" - by then the DOM is already built, so the block does no visible harm.
sequenceDiagram
participant P as HTML Parser
participant N as Network
participant J as JS Engine
Note over P: parsing DOM...
P->>P: hits <script src> (no async/defer)
P->>P: PAUSE DOM construction
P->>N: fetch script
N-->>P: script bytes
P->>J: execute script fully
J-->>P: done
Note over P: RESUME DOM construction
This sequence diagram shows what happens when the HTML parser hits a blocking script. The parser is building the DOM, then encounters a script tag with no async or defer attribute. It pauses DOM construction entirely. It requests the script from the network and waits for the bytes to arrive. It hands the script to the JavaScript engine, which executes it fully. Only when execution is done does the parser resume building the DOM. During this whole window, no further DOM is built and nothing new paints.
async and defer: telling the parser not to wait
You don't have to choose between "script in head, page blocked" and "script at bottom, runs late." Two attributes give you control, and knowing the difference cold is a near-guaranteed interview question.
defer: download the script in parallel with parsing (don't block), but wait to execute until the DOM is fully built, then execute in document order, right before DOMContentLoaded. This is what you want for almost all application scripts - they download early but never interrupt parsing, and they run in a predictable order with the full DOM available.
async: download in parallel with parsing, but execute as soon as it arrives, pausing the parser at that moment, in no guaranteed order. This suits independent, order-insensitive scripts - analytics, a chat widget - that don't touch each other and don't depend on the DOM being complete.
| Attribute | Download | Execution | Order | Use for |
|---|---|---|---|---|
| (none) | blocks parsing | immediate, blocks parsing | document order | rarely - legacy |
async |
parallel | as soon as ready (pauses parser) | not guaranteed | independent scripts (analytics) |
defer |
parallel | after DOM complete, before DOMContentLoaded | document order | app code, anything DOM-dependent |
graph TD
Start[Script in the head] --> Q{Does it depend on<br/>other scripts or the DOM?}
Q -->|yes| DEFER[Use defer:<br/>parallel download,<br/>ordered, after DOM]
Q -->|no, fully independent| ASYNC[Use async:<br/>parallel download,<br/>runs ASAP]
Q -->|must run inline now,<br/>rare| BLOCK[Plain script:<br/>accept the block]
This is a decision flowchart for placing a script in the head. Start with a script in the head and ask: does it depend on other scripts or on the DOM? If yes, use defer, which downloads in parallel, preserves order, and runs after the DOM is built. If it's fully independent, use async, which downloads in parallel and runs as soon as it's ready. If it must run inline immediately, which is rare, use a plain script and accept that it blocks parsing.
The preload scanner: the browser's secret head start
There's a clever optimization worth knowing because it impresses interviewers and explains some surprising behavior. Even while the main parser is blocked on a script, a secondary preload scanner races ahead through the raw HTML looking for resources it can start downloading early - images, stylesheets, other scripts. So the browser isn't entirely idle during a block; it's speculatively fetching what it can see coming.
The practical consequence: resources discoverable in the initial HTML get a head start, but resources that only appear after JavaScript runs (an image whose URL is set by a script, say) are invisible to the preload scanner and download late. This is one concrete reason server-rendered HTML can load critical images faster than a client-rendered app - the URLs are right there in the markup for the scanner to find.
CSS parsing: render-blocking, and quietly script-blocking too
CSS parses into the CSSOM, and as we established, CSS is render-blocking - the browser won't paint until the CSSOM is ready. But there's a second, sneakier interaction that catches people.
CSS can block your JavaScript
Consider this order: a stylesheet, then a script. You might assume the script runs as soon as it's parsed. It doesn't - if a script might read computed styles (and the browser can't know it won't, since getComputedStyle exists), it must wait for the CSSOM to be ready before executing, so it sees correct style information. So a slow stylesheet can delay not just your paint but your scripts that come after it.
This is the inverse of the script problem and it's why the combination matters: CSS blocks rendering and can block scripts; scripts block parsing and can be blocked by CSS. They're entangled. The senior move is to understand the entanglement, not memorize one rule.
Important: Two rules fall straight out of this, and you can derive them live instead of reciting them: (1) Put render-blocking CSS in the
<head>and keep it small - ideally inline the critical above-the-fold CSS so first paint doesn't wait on a network round trip. (2) Usedeferon application scripts so they download early but never pause parsing. Both rules are just "don't let a resource block a pipeline stage it doesn't need to."
Making CSS less blocking
You're not stuck with all-or-nothing blocking. The media attribute lets the browser deprioritize CSS that doesn't apply right now: <link rel="stylesheet" href="print.css" media="print"> won't block rendering on screen because it's only for printing. The browser still downloads it, but at a lower priority and without holding up your paint. The same pattern applies to stylesheets gated on media queries that don't currently match. It's a small lever, but it's the kind of detail that shows you understand blocking is a spectrum, not a switch.
Putting the two parsers together
Here's the mental model to walk away with. The HTML parser builds the DOM incrementally and pauses for blocking scripts. The CSS parser builds the CSSOM and blocks painting until it's done - and can block scripts that follow it. Your job, when arranging the <head>, is to make sure neither parser stalls the other unnecessarily:
- Critical CSS: inline or in
<head>, small. (Unblocks paint fast.) - Non-critical CSS: load with a non-blocking pattern or
mediagating. - Scripts:
deferby default;asyncfor independent ones; never plain-blocking in the<head>without a reason.
That arrangement isn't a checklist to memorize - it's the consequence of how the parsers behave. If you understand the machinery, you can rebuild the checklist from scratch under any constraint an interviewer throws at you.
Interview: A favorite follow-up: "What's the difference between
asyncanddefer, and which would you use for your main app bundle?" The answer that lands: "Both download in parallel without blocking the parser.asyncruns as soon as it arrives, in no guaranteed order, which is fine for independent scripts like analytics.deferwaits until the DOM is built and runs in order - that's what I'd use for the app bundle, because it depends on the DOM existing and on a predictable execution order." If you can also mention the preload scanner, you've shown depth most candidates don't reach.
Two events that tell you when parsing is "done"
Parsing produces two milestones you'll see constantly in real code, and people routinely confuse them. Understanding them precisely is both practically useful and a common interview probe.
DOMContentLoaded fires when the HTML has been fully parsed and the DOM is built - and all defer scripts have run - but it does not wait for images, stylesheets-as-resources, or other sub-resources to finish downloading. It's the "the structure is ready, you can safely query and manipulate the DOM" signal. This is why initialization code typically hangs off this event: by the time it fires, every element your script wants to touch exists.
load fires later - only once everything has finished: images, fonts, stylesheets, iframes, the lot. It's the "the page is completely, visually finished downloading" signal. It fires much later than DOMContentLoaded on an image-heavy page, which is exactly why you almost never want to gate interactivity on load - you'd be making users wait for the last image to download before buttons work.
| Event | Fires when | Waits for images/subresources? | Use for |
|---|---|---|---|
DOMContentLoaded |
DOM built, defer scripts run | No | Initializing UI, attaching handlers |
load |
Everything downloaded | Yes | Work that truly needs all assets (rare) |
The relationship back to parsing: a blocking script delays DOMContentLoaded, because the DOM isn't "done" until the parser, which the script paused, finishes. A defer script also delays it, but only briefly and predictably, since defers run in order right before the event. An async script may fire before or after DOMContentLoaded depending on when it arrives - which is one more reason async is only for code that doesn't care about the DOM being complete.
A note on module scripts
Modern code uses ES modules - <script type="module"> - and they have a quietly important default: module scripts are deferred by default. You don't need to add defer; a module script downloads in parallel and executes after the DOM is parsed, in order, automatically. They're also evaluated only once even if imported multiple times, and they run in strict mode. The practical upshot is that the modern default is already the well-behaved one - the era of blocking scripts in the <head> is mostly behind us if you're using modules. But you still need to understand the underlying blocking model, because you'll meet legacy code, third-party snippets, and inline scripts that don't follow the modern defaults, and those are where the parser-blocking bugs still live.
Building an optimal <head>, derived from first principles
Let's assemble everything into the arrangement a senior engineer reaches for, and notice that we're deriving it from the parser behavior, not reciting a checklist:
- Charset and viewport meta first - tiny, and the browser needs the charset early to decode the bytes correctly.
preconnect/dns-prefetchhints for known critical origins (API, CDN, fonts) - start warming connections immediately, since this is the earliest the browser sees them.- Inline critical CSS - the above-the-fold styles, inlined so first paint waits on zero network round trips.
preloadfor the LCP resource (the hero image or key font) - make it discoverable to the preload scanner even if JavaScript would otherwise set it.- Non-critical CSS loaded non-blocking - the rest of the stylesheet, loaded so it doesn't hold up first paint.
- Scripts with
defer(or module scripts) - download in parallel, execute after the DOM, in order.
Every line of that is a direct consequence of a rule from this lesson: warm connections early, don't block paint on CSS, make critical resources discoverable, don't pause parsing with scripts. If an interviewer changed a constraint - "you can't inline CSS" or "this script must run before first paint" - you could re-derive the right arrangement on the spot, because you understand the why. That's the difference between knowing the answer and owning the model.
Interview: If asked "walk me through optimizing the
<head>of a slow page," resist listing tips at random. Narrate it as unblocking the parsers in order: warm connections with hints, unblock paint by inlining critical CSS and deferring the rest, make the LCP element discoverable withpreload, and keep scripts off the parser's back withdefer. Framing it as "I'm removing blocks from the HTML and CSS parsers in the order the browser encounters them" shows you understand the mechanism, and mechanism-level answers are what get the senior checkmark.
The real-world culprit: third-party scripts
Worth naming explicitly, because in practice this is where parser-blocking actually bites: your own scripts are usually well-behaved because you control them, but third-party tags - ad networks, analytics, A/B-testing snippets, chat widgets, tag managers - are frequently injected as plain blocking scripts, sometimes high in the <head>, and sometimes they inject further scripts at runtime. A single poorly-placed marketing tag can block parsing, delay first paint, and tank your metrics, and it won't show up in your own codebase because it arrived through a tag manager someone in marketing controls. The defensive moves are the same rules applied to code you don't own: load third-party scripts with async or defer wherever the vendor allows it, lazy-load non-essential widgets until after the page is interactive, and treat any synchronous third-party tag in the <head> as a performance bug to escalate. When an interviewer asks why a real production page is slow, "an unoptimized third-party script blocking the parser" is one of the most common true answers, and naming it shows you've debugged real sites, not just toy pages.
What you should carry out of this lesson
HTML parsing is incremental but pauses on blocking scripts, because a script could rewrite the DOM being built. defer and async let scripts download in parallel without that block - defer for ordered, DOM-dependent app code, async for independent fire-and-forget scripts. CSS is render-blocking by design and can also delay scripts that follow it, because those scripts might query computed styles. The preload scanner softens the cost of blocks by speculatively fetching resources it can see in the raw HTML.
All of this decides when the rendering pipeline is allowed to run for the first time. But the pipeline doesn't just run once - every time you change the page after it's loaded, you re-trigger parts of it. Some of those re-triggers are cheap and some are brutally expensive, and knowing which is which is the difference between a 60fps interface and a janky one. That's reflow and repaint, and it's next.