Frontendwaterfallrender blockingcritical pathresource priorityweb

Reading the Browser Waterfall

HTML discovers CSS, CSS blocks the paint, script blocks the parser, and the image nobody prioritised is fetched last. The waterfall shows a dependency chain — and the difference between a resource being downloaded and the page being usable.

▶ Run the labFollow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
Which resource is actually holding up the first paint, and which ones only look slow because they were queued behind it?
Symptom
The page shows blank or unstyled content for seconds. Individual resources all download quickly when inspected on their own, so nothing looks obviously wrong.
Signal
The resource waterfall with start times, not just durations — the gaps and the queue order carry the information. The misleading signal is a list of resources sorted by size, which tells you nothing about what blocked what.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

A dependency chain, drawn sideways

Web-specific · Browser resource loading; the shape depends on HTTP version, connection reuse and priority hints

A waterfall is not a list of downloads. It is a picture of *discovery*: the browser cannot fetch what it has not parsed, so a resource referenced by a stylesheet or injected by a script is discovered late no matter how small it is. The long bars are often symptoms; the position of a bar is usually the cause.

In the waterfall below, the hero image is 620ms of download — but the actionable fact is that it *starts* at 1600ms, because it is injected by the script that finishes executing at 1560ms. Making the image smaller shaves a little off the end. Making it discoverable in the initial HTML moves its start by more than a second.

This is the same critical-path reasoning as in The Critical Path Is the Only Path That Pays, applied to a browser rather than a service graph: find the chain of dependencies where each link cannot begin until the previous ends, and shorten a link that is actually on it.

First visit, mid-range phone. The hero image is discovered by script, not by the parser
critical pathILLUSTRATIVE
0600120018002400
GET / (HTML)420 ms
app.css — render blocking260 ms
app.js — download520 ms
app.js — parse, compile, execute610 ms
GET /api/products380 ms
hero.jpg — injected by script620 ms
LCP paint60 ms
GET / (HTML)TTFB plus download; the parser can only start once bytes arrive
app.css — render blockingNothing paints until this lands and parses
app.js — downloadDiscovered by the preload scanner, fetched in parallel with CSS
app.js — parse, compile, executeMain-thread CPU; scales with the device, not the connection
GET /api/productsCannot start until script runs — a second round trip in series
hero.jpg — injected by scriptThe LCP element. Its start time is the problem, not its size
LCP paintThe user finally sees the thing they came for

Render-blocking is a chain, not a list

Web-specific · HTML parser, preload scanner and render-blocking semantics; details differ between browser engines

Two mechanisms hold up the first paint, and they behave differently. A stylesheet blocks *rendering*: the browser will happily keep parsing HTML and discovering resources, but it will not paint until the CSS has arrived and been parsed. A classic synchronous script blocks *parsing*: the parser stops dead, so nothing after that tag is even discovered until the script has downloaded and run.

The parser-blocking case is the more damaging one and the easier to miss, because the cost does not appear as a long bar on the script — it appears as an unexplained gap before everything that follows. A 200ms script in the head can delay the discovery of a hero image by 200ms plus the entire cost of executing it.

The preload scanner mitigates some of this by speculatively looking ahead for fetchable URLs while the parser is blocked, which is why <img src> in the initial HTML behaves so much better than an image URL that only exists after JavaScript runs. Anything the scanner cannot see — script-injected resources, CSS url() references, dynamic imports — is discovered late by construction.

discovereddiscoveredrender-blockingmust execute firstdiscovered lateLCP elementHTML parsedCSS fetched + parsedJS fetched + executedScript injects <img>Hero fetchedLCP paint
UserLLMAgentToolDataDecisionHumanGuardrail

Downloaded is not usable

Web-specific · Browser main-thread processing after resource download

The waterfall ends when the last byte arrives, and users do not care about bytes. Between "downloaded" and "usable" sit parse, compile, execute, layout and paint — main-thread work that a network-shaped view of the world renders invisible.

This is why a team can move a site to a CDN, watch every bar in the waterfall shrink, and receive exactly the same complaints. The bytes arrive faster; the 600ms of script execution that follows them is unchanged, because it is CPU on a device you do not control.

Read the waterfall together with a main-thread timeline. If the bars are short and the gap after them is long, the constraint is the device, and the fix belongs in JavaScript Costs Four Times, Not Once or Layout, Paint and the Main Thread rather than in the network.

The same page after moving static assets to a CDNILLUSTRATIVE
SignalValueWhat it tells youVerdict
Total transferred bytesunchangedA CDN moves bytes closer; it does not remove themnormal
Asset download time520ms → 180msGenuine improvement in the network portionnormal
JS parse + compile + execute610ms → 610msDevice CPU is untouched by where the bytes came fromsmoking gun
LCP, field p75 mobile4.2s → 3.9sMoved by roughly the network saving and no moresuspect
INP, field p75 mobile380ms → 385msUnchanged — the main thread is just as busy as beforesmoking gun

Key points

  • A waterfall shows discovery order and dependency, not just size — a bar's start time is usually more actionable than its length.
  • Stylesheets block rendering; synchronous scripts block parsing, which also blocks the discovery of everything after them.
  • The preload scanner can only speculatively fetch what is visible in the HTML; script-injected and CSS-referenced resources are late by construction.
  • The waterfall ends at the last byte; the user waits for parse, compile, execute, layout and paint that a network view does not show.
  • Shortening a resource that is not on the critical chain changes the picture and not the experience.

Browser Waterfall

Change an input and watch which number moves — and which one does not.

Where a 3.2 second page actually goes
Page load · first contentful paint at 2,100 ms
critical pathILLUSTRATIVE
0800160024003200
GET / (HTML)320 ms
main.css210 ms
bundle.js (480 KB)890 ms
parse + compile + execute640 ms
GET /api/dashboard190 ms
hero.jpg (1.4 MB)1400 ms
brand.woff2240 ms
GET / (HTML)Nothing can start until the HTML arrives and is parsed.
main.cssRender-blocking: the browser will not paint until this is fetched and parsed.
bundle.js (480 KB)Download is only the first cost.
parse + compile + executeOn a mid-range phone this is often longer than the download. Gzip does nothing for it.
GET /api/dashboardThe API everyone blames — 190 ms of a 3.2 second page.
hero.jpg (1.4 MB)Large, but it downloads in parallel and does not block the paint. Big is not the same as blocking.
brand.woff2Can block text rendering depending on the font-display strategy.

The API call is 190 ms of 3,200. The largest file — a 1.4 MB image — blocks nothing. The actual critical path runs HTML → CSS → JavaScript download → JavaScript execution, and the single biggest item on it is parse + compile + execute, which no amount of compression improves.

Follow the diagnosis

The causal chain, hop by hop — and the readings that invite the wrong conclusion.

  1. 1
    Parser → CSS: the stylesheet is discovered immediately and fetched; the browser will not paint anything until it parses.
  2. 2
    Parser → script: the bundle is fetched in parallel by the preload scanner, but must execute on the main thread before it can do anything.
  3. 3
    Script → DOM: the hero image element is created at 1560ms, which is the first moment the browser learns that hero.jpg exists.
  4. 4
    Browser → CDN: hero.jpg is fetched starting at 1600ms — a fast fetch that started far too late.
  5. 5
    Layout → user: the LCP element paints at 2280ms, having spent more time waiting to be discovered than waiting to be downloaded.
What this evidence makes people conclude — wrongly
  • "The hero image is the biggest bar, so compress it." Its start time is the problem; compressing it saves a fraction of what moving its discovery saves.
  • "All the resources download quickly, so the network is fine." Fast downloads that start late still produce a slow page.
  • "Moving to a CDN will fix it." A CDN shortens the bars and leaves the main-thread work — and often the discovery order — exactly as it was.
  • "The script is only 40ms of execution in my profiler." Check the device: execution cost on a mid-range phone can be several times a laptop's.
  • "Nothing is blocking — there are no long bars." Look for gaps, not bars. A parser-blocking script shows up as absence, not length.

Measure, fix, validate

An optimization is not finished until the metric that motivated it has moved.

How to measure it
  • • The full resource waterfall for a cold first visit on a throttled mid-range device profile, with start times and priorities visible.
  • • Which element is the LCP element, and the timestamp at which its fetch was *initiated* rather than completed.
  • • Resource Timing entries for the LCP resource: `startTime`, `responseEnd`, and the initiator that discovered it.
  • • A main-thread timeline for the same load, to separate network bars from the execution that follows them.
  • • Whether each blocking resource is parser-blocking or render-blocking — they need different fixes.
What actually fixes it
  • • Make the LCP resource discoverable in the initial HTML markup so the preload scanner finds it immediately, and give it explicit fetch priority.
  • • Remove parser-blocking scripts from the head, or mark them so they do not stop the parser discovering later resources.
  • • Inline the small amount of CSS needed for the first screen and load the rest without blocking the paint.
  • • Cut or defer script that the first screen does not need, which attacks both the download bar and the execution gap after it (see [[javascript-bundle-cost]]).
  • • Only then optimise the bytes themselves — format, compression and dimensions (see [[image-performance]]).
How you know it worked
  • • Re-run the same throttled cold-load profile and confirm the LCP resource's *start* time moved, not only its duration.
  • • Confirm field LCP p75 for the affected route and device segment moved over a stable window, not just the lab number.
  • • Check the main-thread timeline: if the bars shrank and the gap did not, the remaining cost is execution and needs a different fix.
  • • Verify no resource was starved by the new priority — over-prioritising the hero can simply push the delay onto something else.
What it costs
  • • Inlining critical CSS duplicates bytes across pages and complicates caching — a real cost paid for an earlier first paint.
  • • Preloading and priority hints compete for the same bandwidth; prioritising everything prioritises nothing.
  • • Server-rendering the hero markup to make it discoverable can increase TTFB, trading backend time for earlier discovery.
  • • Deferring script improves the paint and can worsen interaction readiness if the deferred work then lands during a user's first tap.
Stop it coming back
  • A CI check that the LCP element for each key route is present in the server-rendered HTML rather than script-injected.
  • A budget on render-blocking bytes in the head, enforced per commit.
  • A synthetic waterfall snapshot per commit for the top routes, diffed for newly-blocking resources.
  • A review rule that any newly added third-party script states where it loads and whether it blocks the parser.

Accuracy

Performance numbers are conditional. These are the conditions.

What these numbers depend on
  • WEB-SPECIFICDiscovery, preload scanning and render-blocking semantics are browser behaviours; the details vary between engines and change between versions.
  • ILLUSTRATIVEThe waterfall timings are invented to show the shape of a script-injected LCP. Real timings depend on device, connection, HTTP version and cache state.
  • ENVIRONMENT-SPECIFICThe number of resources fetched in parallel and their priority handling depend on the HTTP version and connection reuse — HTTP/1.1 and HTTP/2 produce visibly different waterfalls.

Misconceptions

Claim
“The longest bar in the waterfall is the bottleneck.”
Reality
The longest bar is often a symptom of queueing behind something else. The bar that starts latest relative to when it *could* have started is usually the actionable one.
Claim
“All scripts block rendering.”
Reality
Blocking behaviour depends on how the script is loaded. Classic synchronous scripts block the parser — which also blocks resource discovery — while deferred and module scripts do not, and that distinction changes the fix.
Claim
“A faster network fixes a slow waterfall.”
Reality
It shortens the bars. It does not change discovery order, and it does nothing to the parse, compile and execute time that happens after the bytes land.

Apply it