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.

Where each hint stops on the connection ladder for fonts.gstatic.com Three stacked timelines for the same cold request. Without a hint the browser spends 92 milliseconds on DNS, 80 on the TCP handshake and 86 on TLS before 142 milliseconds of request and response. With dns-prefetch only the DNS bar is removed, saving 92 milliseconds. With preconnect all three setup bars are removed, saving 258 milliseconds, at the cost of holding an idle socket open until the request arrives. Where each hint stops — fonts.gstatic.com, cold cache, 80 ms round trip, TLS 1.3 no hint 3 serial round trips DNS 92 TCP 80 TLS 86 request + response 142 dns-prefetch name resolved early TCP 80 TLS 86 request + response 142 -92 ms preconnect socket parked and idle request + response 142 -258 ms, if the request arrives in time 0 100 ms 200 ms 300 ms 400 ms The DNS rung is the variable one: 92 ms cold here, 0 ms whenever the resolver or the OS already holds the answer. The TCP and TLS rungs are 2 round trips of arithmetic. They are never free, and dns-prefetch never removes them.

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.

Three questions that decide which hint an origin gets A decision tree with three questions asked in order. If the first request to the origin does not start within ten seconds, the answer is dns-prefetch only, because the socket would expire unused. If measured setup cost is under fifty milliseconds, the answer is no hint at all, because the origin already coalesces onto an open socket. If the origin is not in the top four by setup cost times probability of use, the answer is dns-prefetch. Only an origin that passes all three gates earns a preconnect. Ask in this order — an early no makes the later questions irrelevant Will the first request to this origin start under 10 s? dns-prefetch only the socket would expire unused No Yes Is measured setup cost above 50 ms on a cold load? no hint at all it already reuses an open socket No Yes Top 4 by setup cost times probability of use? dns-prefetch keep the sockets for richer origins No Yes preconnect, with crossorigin matching the eventual request's credential mode emitted from a Link header or a 103 response, ahead of the stylesheet Each no is still a win: the cheapest hint is the one you did not have to spend a socket on.

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.

Seven origins ranked by cold setup cost, and the hint each earns A ranked horizontal bar chart. fonts.gstatic.com costs 268 milliseconds, cdn.shop-assets.example 244, api.payments.example 231 and tags.analytics.example 226; all four earn a preconnect. widget.chat.example costs 219 milliseconds but its first request starts at 14 seconds, so it gets dns-prefetch, as does img.reviews.example at 188 milliseconds once the budget of four is spent. static.same-cdn.example costs 34 milliseconds because it coalesces onto an open connection, and gets no hint. Cold-load setup cost per origin — the socket budget stops after four DNS + TCP + TLS from Resource Timing, 4G. Right-hand figure = when the first request starts. 50 ms floor fonts.gstatic.com 268 ms 0.4 s cdn.shop-assets.example 244 ms 0.9 s api.payments.example 231 ms 1.2 s tags.analytics.example 226 ms 2.8 s widget.chat.example 219 ms 14.0 s img.reviews.example 188 ms 6.2 s static.same-cdn.example 34 ms 1.1 s 0 50 100 150 200 250 300 ms preconnect (4) dns-prefetch (2) no hint (1) widget.chat.example costs 219 ms, but its first request lands at 14 s — long after the socket is reclaimed.

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, tls and firstAt per 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-Origin header 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 preconnect consideration every origin whose firstAt exceeds roughly 10 s. These become dns-prefetch candidates 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 crossorigin from the eventual request, not from habit. For each winner, find the actual fetch and match it: CORS without credentials takes bare crossorigin, credentials: 'include' takes crossorigin="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 as Link response 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, tcp and tls at 0 on its first request. A non-zero tls with a zero dns is 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