preconnect vs dns-prefetch: A Decision Matrix
You added <link rel="dns-prefetch"> for the origin serving your web font, the Lighthouse opportunity went quiet, and the font request still shows 166 ms of Initial Connection plus SSL — because dns-prefetch stopped at the one phase that was already cheap.
Root cause: the two hints stop at different rungs of one ladder
Both hints drive the same machinery. A cross-origin fetch cannot start until the browser has an established, credential-matched socket for that origin, and building one is a fixed ladder: resolve the hostname, complete the transport handshake, complete the TLS handshake. dns-prefetch climbs the first rung and stops. preconnect climbs all three and parks the result. Everything else — the socket cost, the expiry, the credential keying — follows from where each one stops.
The asymmetry that makes this a decision rather than a preference is that the three rungs are not the same size, and only one of them is reliably expensive. DNS resolution is the most variable and the most frequently free: the answer may already be in the browser’s own host cache, the OS resolver cache, or the recursive resolver one hop away, and on a repeat view within the TTL it costs nothing at all. The transport and TLS rungs are neither variable nor free. A new TCP connection is one round trip for the SYN exchange, TLS 1.3 adds one more for ClientHello and ServerHello, and TLS 1.2 adds two. On the 80 ms round trip that a throttled 4G profile simulates, that is a deterministic 160 ms that dns-prefetch cannot touch, sitting behind a 0–92 ms rung that it can. Its expected value is small and noisy; preconnect’s is large and predictable.
What preconnect charges for that is a socket, and a socket has an expiry. Chromium’s network service parks the warmed connection in the socket pool keyed by the tuple of network anonymization key, scheme, host, port and privacy mode, and reclaims idle unused sockets on a timer measured in seconds — roughly ten in the renderer’s accounting, though the exact figure moves between releases. An origin whose first real request lands at fourteen seconds therefore pays the full handshake anyway, on top of a handshake you already paid for and threw away. The pool is finite too: six connections per origin key and a global ceiling in the mid-hundreds, and every handshake you start in <head> shares the same congestion window as the HTML and CSS that are trying to arrive.
Read the third row as a conditional, not a guarantee. Its 258 ms only materialises if the request shows up while the socket is still parked, and the socket is only reusable if it was keyed in the credential mode the fetch will ask for — the failure the parent topic on preconnect and dns-prefetch covers in detail.
The matrix
Every row is a dimension on which the two hints genuinely differ. The last two rows are the ones that decide most real cases.
| Dimension | dns-prefetch |
preconnect |
|---|---|---|
| Rungs completed | Hostname resolution | Resolution, transport handshake, TLS or QUIC handshake |
| Round trips removed | 0 or 1, depending on resolver state | 2 (TLS 1.3) or 3 (TLS 1.2) |
| Typical saving, cold 4G | 0–92 ms | 160–258 ms |
| Resource held | A cache entry, bytes | A socket in the pool, plus its handshake state |
| Expiry | The record’s TTL, usually minutes | Idle-socket reclaim, seconds |
| Cost of over-use | Negligible; extra resolver queries | Pool pressure, handshakes contending with the HTML |
Sensitive to crossorigin |
No | Yes — the socket is keyed by credential mode |
| Safe count per page | 10 or more | 4 |
| Right when | The first request is late, speculative, or the origin is cheap | The first request is early, certain, and the origin is expensive |
The “safe count” asymmetry is the practical heart of it. dns-prefetch is close to free, so you can afford to be generous and wrong; preconnect spends a scarce resource at the busiest moment of the page’s life, so it has to be earned. That makes hint selection a ranking problem, which is the same discipline as setting a third-party request budget: a fixed allowance, spent on measured value.
Three questions, in this order
Run each cross-origin host through the same three gates. Order matters — question one disqualifies origins that question two would happily recommend.
The second gate is the one teams skip. An origin that resolves to an address you are already connected to, and whose certificate covers the host you are connected to, gets coalesced onto that existing connection and reports near-zero setup. Warming it wastes a slot on a handshake the browser was never going to perform.
Minimal reproduction
Three origins from the same page, one per outcome, each annotated with the gate it passed or failed:
<!-- Passed all three gates: 268 ms of cold setup, first request at 0.4 s, top of
the ranking. crossorigin is load-bearing, not decoration — a font fetch is an
anonymous CORS request, and a socket warmed in the wrong credential mode is
keyed separately, so the fetch opens a second one and the hint buys nothing. -->
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Also passed, and deliberately WITHOUT crossorigin: the image CDN is fetched as
a plain no-cors subresource, so the pre-warmed socket must not carry the CORS
key. Adding crossorigin here breaks the reuse exactly as omitting it does above. -->
<link rel="preconnect" href="https://cdn.shop-assets.example">
<!-- Failed gate one: the chat widget's first request lands at 14 s, past the idle
reclaim, so a warmed socket is closed before it can be used. Resolving the name
early is the only part of the ladder still bankable at that distance. -->
<link rel="dns-prefetch" href="https://widget.chat.example">
The measurement that produced those three verdicts is short enough to paste into the console on a cold load:
// Ranking rationale: only the FIRST request to an origin pays for connection setup —
// every later one reuses the socket — so averaging across entries understates the
// winners. Take the earliest entry per origin and keep startTime with it: an origin
// whose first request begins after the idle reclaim can never cash a preconnect,
// however expensive its handshake is.
const firstByOrigin = new Map();
for (const e of performance.getEntriesByType('resource')) {
const origin = new URL(e.name).origin;
const seen = firstByOrigin.get(origin);
if (!seen || e.startTime < seen.startTime) firstByOrigin.set(origin, e);
}
const ranked = [...firstByOrigin.entries()]
.map(([origin, e]) => {
// secureConnectionStart splits the connect interval into transport and TLS.
// It is 0 on a plaintext or reused connection, so guard before subtracting.
const tls = e.secureConnectionStart > 0 ? e.connectEnd - e.secureConnectionStart : 0;
const tcp = e.secureConnectionStart > 0
? e.secureConnectionStart - e.connectStart
: e.connectEnd - e.connectStart;
const dns = e.domainLookupEnd - e.domainLookupStart;
return { origin, dns, tcp, tls, setup: dns + tcp + tls, firstAt: e.startTime };
})
.filter((r) => r.setup > 50) // gate two: below this, neither hint pays
.sort((a, b) => b.setup - a.setup);
console.table(ranked);
One caveat decides whether you can trust the output: a cross-origin entry reports zero for domainLookupStart through connectEnd unless the origin sends Timing-Allow-Origin. An all-zero row is unmeasured, not free. Confirm those hosts in the Network panel’s Timing tab, where the browser shows its own phases regardless of the header, or with a synthetic run.
Rank before you spend
Seven cross-origin hosts on a production product page, ranked by cold setup cost. The budget of four is not a rule of thumb pulled from nowhere: it is where the marginal handshake starts costing the HTML more congestion window than the origin it warms will return.
Note that rank order alone would have given widget.chat.example a socket. It is the fifth most expensive origin on the page and the fourth most expensive that anyone would think to warm, yet it is disqualified before the budget is even consulted, purely on when its first request arrives. Cost tells you how much a hint could be worth; timing tells you whether it can be collected at all.
Deterministic fix protocol
- [ ] 1. Collect setup cost for every cross-origin host. Run the ranking snippet on a cold load with throttling on. Record
dns,tcp,tlsandfirstAtper origin. Repeat it on a warm load — the two lists differ, and the cold one is what first-time visitors experience. - [ ] 2. Fix the unmeasurable rows first. Any origin reporting all zeros without a
Timing-Allow-Originheader is unmeasured. Read its phases from the Network panel’s Timing tab before ranking it, or you will rank it as free. - [ ] 3. Apply gate one — timing. Drop from
preconnectconsideration every origin whosefirstAtexceeds roughly 10 s. These becomedns-prefetchcandidates regardless of how expensive they are. - [ ] 4. Apply gate two — the 50 ms floor. Remove origins whose measured setup is under 50 ms. They are already coalesced, resolver-cached, or same-connection; a hint changes nothing and still occupies a
<head>line someone will later have to justify. - [ ] 5. Apply gate three — rank and cut at four. Multiply each survivor’s setup cost by the share of sessions that request it, sort descending, and take the top four. Everything below the cut gets
dns-prefetch. - [ ] 6. Set
crossoriginfrom the eventual request, not from habit. For each winner, find the actual fetch and match it: CORS without credentials takes barecrossorigin,credentials: 'include'takescrossorigin="use-credentials", and a plain no-cors subresource takes neither. - [ ] 7. Move the winners upstream. A
<link>in<head>cannot fire until the HTML starts arriving. Re-emit the four asLinkresponse headers, or as a 103 Early Hints response, so the handshakes overlap server think-time instead of the HTML download. - [ ] 8. Verify all three phases read zero. Re-run the snippet. Each preconnected origin must report
dns,tcpandtlsat 0 on its first request. A non-zerotlswith a zerodnsis the credential-mode mismatch from step 6, not a late hint. - [ ] 9. Check for new queueing. Compare Queueing and Stalled times against the baseline in the Network panel. If they grew, the extra handshakes are contending with the critical path — a symptom worth reading against request queueing and stalled time — and the budget of four is one too many for this page.
Step 7 is the largest single win in the list and the one most often skipped, because it lives in the CDN config rather than the template:
Link: <https://fonts.gstatic.com>; rel=preconnect; crossorigin
Link: <https://cdn.shop-assets.example>; rel=preconnect
Link: <https://api.payments.example>; rel=preconnect
Link: <https://tags.analytics.example>; rel=preconnect
Those four lines start their handshakes at the response headers rather than at the parser’s first pass over <head>, which on a slow origin moves the entire 258 ms into time the browser was going to spend waiting anyway.
Before/after metrics
One product page, seven cross-origin hosts, 4G profile at 80 ms round trip, cold cache. “Before” carried seven dns-prefetch tags — the generous, wrong configuration this page exists to correct. “After” applied the three gates and emitted the four winners as Link headers.
| Metric | Before (7 × dns-prefetch) | After (4 preconnect + 2 dns-prefetch) | How to verify |
|---|---|---|---|
Setup time on fonts.gstatic.com |
166 ms | 0 ms | Network → Timing → Initial connection + SSL |
| Summed setup on the top four origins | 969 ms | 0 ms | setup field, ranking snippet |
| Largest Contentful Paint | 3.24 s | 2.71 s | LCP PerformanceObserver, throttled |
| First font byte | 412 ms | 154 ms | responseStart on the font entry |
| Sockets warmed and never used | 0 | 0 | net-internals socket pool, after gate one |
Hint elements in <head> |
7 | 2 | View source; four moved to headers |
| Round trips removed per session | 1 (DNS only) | 8 | 2 RTT × 4 origins |
The row that justifies the whole exercise is the first. Seven dns-prefetch tags removed one variable rung from one origin; four preconnect hints removed eight deterministic round trips, and did it with fewer elements in <head>. The generous configuration was not merely weaker — it was more markup for less effect.
FAQ
Should I still ship dns-prefetch as a fallback next to every preconnect?
Almost never now. The paired-hint pattern dates from the years when Safari and older Edge lacked preconnect support, and every engine shipping today implements it. The pair is not free either: it is a second element the parser processes, and in Chromium the dns-prefetch is a no-op the moment the preconnect resolves the same name. It also fails to rescue the case people reach for it to cover — when Safari’s tracking protection suppresses a hint for a classified origin, it suppresses dns-prefetch too, so the fallback resolves nothing. Keep the pair only where field data shows real traffic from an engine that needs it.
Does preconnect still pay on a repeat view or over HTTP/3?
Less, and the honest answer is to re-measure rather than assume. A repeat view usually holds a TLS session ticket, collapsing the handshake to one round trip, and QUIC can resume at 0-RTT — which turns a 258 ms cold setup into something nearer 80 ms. The hint still removes that round trip from the critical path, but the ranking moves: an origin at the top of the cold list can fall below the 50 ms floor on a warm one. This is why gate two runs against a cold profile and why the protocol asks for both lists. If most of your traffic is returning visitors, rank on the warm numbers and expect a shorter list of winners.
The preconnect row shows in the waterfall but the resource still handshakes. Why?
Three causes account for nearly all of it. First, the credential mode does not match, so the fetch keys to a different socket than the one you warmed — check for a zero dns alongside a non-zero tcp and tls, which is that failure’s exact signature. Second, the hint is discovered after the preload scanner has already dispatched the request, which happens whenever the <link> sits below the markup that triggers the fetch; moving it to a Link header removes the ordering question entirely. Third, network partitioning: Chromium keys sockets by a network anonymization key derived from the top-level site, so a socket warmed by the top-level document is not reused inside a cross-site iframe, and a widget that loads in one will handshake again no matter what you put in <head>.
Related
- Up: Strategic Preconnect & DNS-Prefetch Usage — the spec-level semantics, engine differences and verification workflow behind this choice
- Automating Preconnect for Third-Party APIs — turning this ranking into a build step so the list cannot rot