Chrome vs Safari vs Firefox: Priority Differences
The identical product page — same HTML, same CDN, same throttling profile — reports a 1.24 s LCP in Chrome, 1.93 s in Firefox and 2.41 s in Safari, and the hero image is the last request to complete in exactly one of them.
Root cause: three schedulers asking three different questions
Every engine runs a speculative pre-parser and every engine keeps a queue, so it is tempting to model them as one mechanism with cosmetic differences. They are not. Each keys the same document on a different set of signals, and the divergence is largest for exactly the resource type you care about most: images.
Chromium computes a ResourceLoadPriority for each fetch — five values, surfaced in DevTools as Lowest, Low, Medium, High and Highest. The inputs are the resource type, whether the request is render-blocking, the element’s position relative to the initial viewport at first layout, and the fetchpriority hint. Chromium then does two things nobody else does. It re-ranks: an image created at Low is promoted once layout establishes that it intersects the initial viewport, and the first handful of images in document order get a floor above the rest. And it throttles: while render-blocking resources are still outstanding, the loader keeps delayable requests to a very small number in flight, so low-band assets cannot steal bandwidth from the stylesheet. That is why Chromium tends to start the hero late and still finish it first.
WebKit assigns a load priority once, at fetch creation, from a type table adjusted by fetchpriority (supported from Safari 17.2). There is no layout-driven promotion for images and no equivalent of Chromium’s hold-back on delayable requests. The practical consequence is the mirror image of Chromium’s: on Safari the hero request is created early, enters the connection immediately, and then spends its life sharing bandwidth with the application bundle, because nothing ever tells the scheduler that this particular image outranks that particular script.
Gecko does not think in a single band at all below the fetch layer. Necko attaches class-of-service flags to each channel — Leader, Follower, Unblocked, Background, Speculative, UrgentStart and the throttleable/tail family — and maps those classes onto HTTP/2 dependency groups. Head stylesheets and blocking scripts are Leaders; images are Followers that depend on the Leader group and are therefore held while Leaders are in flight. A separate tailing mechanism delays tail-class requests, such as speculative and post-load fetches, by a further interval. So Firefox’s hero is not demoted, it is sequenced: it waits for a class to drain rather than for a number to be beaten.
Read the highlighted row as the whole problem in one line. It is not that one engine is slower; it is that the three answers are not even the same sort of answer. Chromium’s cell describes a value that changes during the load, WebKit’s describes a value that never changes, and Gecko’s does not describe a value at all — it describes membership of a group that has to drain first. No amount of tuning within one engine’s model transfers to the others.
The divergence continues onto the wire, which matters if you are also tuning the server. Chromium expresses its band to the origin as an RFC 9218 Priority request header with an urgency and an incremental flag; Firefox additionally builds a dependency structure out of its class groups; Safari signals its own weighting. Your edge therefore receives three different descriptions of the same intent, and how faithfully it reorders them is a separate question again — see HTTP/2 priority trees vs the HTTP/3 Priority header for what actually survives the hop. For this page, treat the wire as noise you cannot fix from the client and concentrate on the band at creation, which you can.
Minimal reproduction
The page below is the smallest document that produces a three-way split. It is deliberately ordinary: one blocking stylesheet, one deferred bundle, a preloaded font, a hero image and a gallery.
<head>
<link rel="stylesheet" href="/app.css">
<!-- Leader in Gecko, Highest in Chromium and WebKit: all three block on it,
which is why the hero's start time is measured from when THIS finishes. -->
<link rel="preload" as="font" type="font/woff2"
href="/f/inter-var.woff2" crossorigin>
<!-- crossorigin is not decoration: a mode mismatch here creates a second
request that occupies a slot the hero needs, in every engine. -->
<script src="/app.js" defer></script>
</head>
<body>
<img src="/img/hero-1600.avif" width="1600" height="900" alt="Picking floor"
decoding="async">
<!-- No hint. Chromium creates this Low and promotes it at first layout;
WebKit creates it Low and leaves it there; Gecko files it as a Follower
behind the Leader group. One element, three scheduling stories. -->
<div class="grid"><!-- 24 more <img> below the fold --></div>
</body>
Serve it from an origin with a fixed 180 ms time to first byte, throttle to a 9 Mbps / 170 ms profile, and load it cold in each engine. The measurements below come from that setup, median of nine runs per engine, with nothing changed between runs but the browser.
The lanes reward a second look. Safari’s hero is on the wire 310 ms before Chrome’s and still paints 1.17 s later, because the only thing that changed is how much of the link it was allowed to keep. Firefox sits between the two for a third reason again — its hero is not competing at all until the Leader group has drained, and once it does start it has the link largely to itself. Three shapes, three causes, one document.
To get these numbers yourself without three sets of developer tools, read them out of Resource Timing, which all three engines implement:
// Ordering rationale: startTime is when the fetch was CREATED, responseEnd is
// when it finished. Chromium's late-start/early-finish shape and WebKit's
// early-start/late-finish shape are indistinguishable if you only log one of
// them — always print the pair, and sort by responseEnd, because that is the
// column that decides the paint.
addEventListener('load', () => {
const rows = performance.getEntriesByType('resource')
.filter((e) => e.initiatorType === 'img' || e.initiatorType === 'link')
.map((e) => ({
file: e.name.split('/').pop(),
start: Math.round(e.startTime), // band assignment happened here
end: Math.round(e.responseEnd), // what the user actually waits for
held: Math.round(e.requestStart - e.startTime) || 0, // queue + connect
proto: e.nextHopProtocol || 'n/a' // empty cross-origin without TAO
}))
.sort((a, b) => a.end - b.end);
console.table(rows);
});
Two portability notes, because they bite on exactly this task. renderBlockingStatus is Chromium-only, so do not build the comparison around it. And nextHopProtocol is an empty string for cross-origin responses that lack a Timing-Allow-Origin header, which is common enough on third-party image hosts that a blank column means “not permitted to tell you”, not “not HTTP/2”.
Deterministic fix protocol
The goal is not to make one engine faster. It is to remove every step whose outcome depends on which engine is executing it, so the three lanes converge. Work top to bottom; each step stands alone.
- [ ] 1. Take the ground truth from your own server. Log arrival order, stream identifier and the
Priorityheader, if any, for one cold load per engine. Browser panels each speak their own vocabulary, so the request order at the edge is the only representation the three engines share. - [ ] 2. Verify every first-paint URL is in the served HTML. View source, not the Elements panel. All three preload scanners work on the byte stream and none can speculate about a URL that a bundle constructs later; a URL that only exists after script execution loses in every engine before priority is even considered.
- [ ] 3. Put
fetchpriority="high"on exactly one element. The single largest above-the-fold element and nothing else. This is the only lever that sets the band at creation in Chromium, WebKit 17.2+ and Gecko 132+ alike. If it appears to change nothing, work through fetchpriority=high not working on your LCP image before assuming an engine bug. - [ ] 4. Demote the tail in writing. Add
fetchpriority="low"andloading="lazy"to below-the-fold images. Chromium already restrains delayable requests while render-blocking work is outstanding; Safari and Firefox do not do it on your behalf, and this is the single highest-yield change for the Safari lane above. - [ ] 5. Delete any plan that depends on layout-driven promotion. The in-viewport boost and the first-images floor are Chromium behaviours. If your reasoning contains the phrase “the browser will notice it is in the viewport”, it is a Chromium-only plan and the other two lanes will not move.
- [ ] 6. Match the font preload’s CORS mode to the eventual fetch. A mismatch produces two requests for one file. The wasted slot hurts everywhere, and hurts most in WebKit, where the duplicate is never re-ranked out of the way.
- [ ] 7. Keep the render-blocking head small. In Gecko this is structural rather than cosmetic: images are Followers of the Leader group, so every extra head stylesheet directly postpones the hero. Trimming the head is the Firefox-specific half of the fix, and it costs the other two engines nothing.
- [ ] 8. Re-measure all three on the same throttle and compare the spread. Success is not only a lower median; it is a smaller gap between the fastest and slowest engine. A spread above roughly 300 ms means an engine-private heuristic is still carrying part of your load order.
- [ ] 9. Segment the field data by engine before closing the ticket. Report LCP at the 75th percentile separately for Chromium, WebKit and Gecko traffic. A change visible only in the Chromium series fixed one scheduler out of three, and mobile Safari is usually the segment with the least headroom.
The tree below is how those steps collapse into one decision you can apply to any single URL on the page.
Notice what the tree does not contain: any branch on the user agent. The differences between the engines are real, but the response to them is a single document that states its intent instead of leaving it to be inferred. That is also why the second leaf is deliberately empty of hints — a page where everything is hinted is a page where the scheduler has nothing left to rank, which reproduces the contention you started with. The general form of that argument is in the parent topic on browser resource priority queues.
Before and after, per engine
Same page, same profile, median of nine cold runs per engine. “After” is steps 3, 4, 6 and 7 applied — one fetchpriority="high", an explicitly demoted gallery, a corrected font preload, and one stylesheet removed from the head. No image was re-encoded and no server code changed.
| Measurement | Chrome 126 | Safari 17.6 | Firefox 129 |
|---|---|---|---|
| Hero band at creation, before | Low (promoted at layout) | Low | Follower |
| Hero band at creation, after | High | High | High |
| Hero request start, before | 560 ms | 250 ms | 640 ms |
| Hero request start, after | 240 ms | 240 ms | 250 ms |
| Hero bytes complete, before | 1,180 ms | 2,360 ms | 1,880 ms |
| Hero bytes complete, after | 1,010 ms | 1,140 ms | 1,070 ms |
| LCP, before | 1.24 s | 2.41 s | 1.93 s |
| LCP, after | 1.06 s | 1.18 s | 1.12 s |
| LCP improvement | −0.18 s | −1.23 s | −0.81 s |
| Gallery bytes before LCP | 0 KB | 890 KB | 210 KB |
The last row explains the shape of the whole table. Chromium was already withholding the gallery, so it had the least to gain and gained the least; Safari was downloading 890 KB of below-the-fold imagery while the hero waited, so writing the demotion down recovered 1.23 s. The spread between the fastest and slowest engine falls from 1.17 s to 0.12 s, which is the number worth reporting — the page now behaves the way the markup says it should rather than the way each scheduler guesses. If you want to reason about the Chromium half of that on its own, how browser fetch priority affects LCP takes the single-engine case apart in detail.
FAQ
Safari and Chrome show different priority labels for the same request. Which one is correct?
Both, because they are not reporting the same quantity. Chromium renders its five-value internal enum as Lowest through Highest; WebKit reports its own load priority for the same fetch; Firefox exposes no band at all, because Necko models scheduling as class-of-service flags rather than one number. Do not diff the columns across browsers. Diff the request arrival order at your own server, which is the single representation all three engines agree to speak.
Should I serve different markup per engine to compensate?
No. Every lever that matters here is additive and safely ignored by engines that do not implement it, so one document satisfies all three. User-agent branching also fragments the shared HTML cache at your edge and doubles the surface you have to measure. The right response to divergent heuristics is to write the intent down explicitly, not to write three documents.
My users are on Firefox 129, which predates fetchpriority support. What actually works there?
Position and class, not hints. In Gecko a head stylesheet or blocking script is a Leader and an image is a Follower, so the working levers before Firefox 132 are removing Leaders from the head, keeping the hero URL in the initial HTML where the speculative parser reaches it, and marking the tail loading="lazy" so those Followers never enter the queue at all. Ship the fetchpriority attribute anyway: it is inert on those builds and correct on newer ones.
Related
- Understanding Browser Resource Priority Queues — parent topic: the band model, the hint syntax and the verification workflow in one place
- How Browser Fetch Priority Affects LCP — the single-engine case: what promotion buys you and where it stops
- Up: Core Browser Loading Mechanics & Priority Queues