866 link tags across 79 pages, and not one of them is a preconnect to the only other host we talk to - DEV Community

DEV Community

Daniel Pertu
Daniel Pertu

Posted on

866 link tags across 79 pages, and not one of them is a preconnect to the only other host we talk to

I counted every <link> tag in the <head> of all 79 pages of our site. 866 of them, 9 to 11 per page:

rel count per page
preload 234 3 on 77 pages
stylesheet 158 2
icon 158 2
manifest 79 1
canonical 79 1
shortcut icon 79 1
apple-touch-icon 79 1

And then the list of what is not there:

preconnect     0
dns-prefetch   0
prefetch       0
modulepreload  0
Enter fullscreen mode Exit fullscreen mode

That matters because the site is not entirely same-origin. It makes exactly one cross-origin request, on every page, and that request gets no hint of any kind.

The three preloads, scored

Two font files, on 77 of 79 pages. 23,108 and 29,288 bytes of Geist, fetched eagerly because a preload is an instruction rather than a suggestion. I established earlier today that our font-sans utility resolves to the system stack, so those 52,396 bytes are fetched and never drawn with. Worth: negative.

The two per-request pages, the homepage and /pricing, send the same two hints as a Link response header instead of HTML tags, which you can see with:

curl -sI https://pub-trivia.app/pricing | grep -i ^link
Enter fullscreen mode Exit fullscreen mode

One image, on the homepage only. rel="preload" as="image" with an imageSrcSet, pointing at the hero image, which is also the page's measured LCP element. This is the one hint on the site doing exactly the job hints exist for. Worth: real.

One script, on all 79 pages, at low priority.

<link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/3vy0pzcfnr4cv.js">
Enter fullscreen mode Exit fullscreen mode

8,373 bytes. I had no idea what that chunk was, so I read it. The strings inside are lazyOnload, afterInteractive, beforeInteractive, data-nscript, setAttributesFromProps, text/partytown, HeadManagerContext. It is next/script itself, the client runtime for the <Script> component, and it ships on every page because of one line in the root layout:

<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="lazyOnload" />
Enter fullscreen mode Exit fullscreen mode

That is the only <Script> in the project. It is in the root layout, so it reaches all 79 pages, and the chunk that implements it is loaded twice over: preloaded at low priority in the head, then fetched async with id="_R_" at the end of the body.

The chain, end to end

  1. One tag in app/layout.tsx for Stripe's pricing-table loader.
  2. 8,373 bytes of next/script on all 79 pages.
  3. Which, after the load event, fetches 10,945 bytes from js.stripe.com.
  4. Which defines a custom element, stripe-pricing-table, that appears in exactly zero of our 79 pages. I grepped the delivered HTML of every one of them and the repository: no element, no reference, no usage.
  5. For a host we send no preconnect to.
  6. While every single response from our site carries x-dns-prefetch-control: on, a header whose entire job is to permit the DNS prefetching we never ask for.

You can watch step 3 happen: open any page, Network tab, filter stripe. It arrives after the page is interactive, which is what lazyOnload promises and delivers.

What the unhinted handshake actually costs

Measured with curl from a fresh process, three runs:

run DNS +TCP +TLS TTFB bytes
1 14.1 ms 18.8 ms 34.8 ms 610 ms 10,945
2 4.2 ms 11.7 ms 23.9 ms 36 ms 10,945
3 2.7 ms 7.3 ms 35.5 ms 606 ms 10,945

Our own origin, for comparison, answers a cached 78,658-byte page in 92 to 127 ms total, TLS included.

Two things in that table are worth more than the handshake numbers. First, two of three runs spent 570 ms waiting after the connection was established, which is a CDN miss on a small file. Second, look at the file's caching:

cache-control: max-age=120
Enter fullscreen mode Exit fullscreen mode

Two minutes. A visitor reading three of our guides over ten minutes fetches that script more than once, across a connection that has to be reopened each time the pool drops it, for an element we never render.

Because the script loads after the load event, none of this is on the critical path, and a preconnect would move maybe 30 ms off a request that nobody is waiting for. That is the honest version. The expensive part is not the hint we are missing, it is the tag we are keeping.

What I am changing

  1. Move the <Script> out of the root layout. If the pricing table is ever used it belongs on /pricing alone. That removes 8,373 bytes of framework from 78 pages, one cross-origin DNS lookup, one TLS handshake and 10,945 bytes from every visit to every other page.
  2. If it stays, <link rel="preconnect" href="https://js.stripe.com" crossorigin> in the head, and drop x-dns-prefetch-control which is doing nothing either way.
  3. Keep the homepage image preload. Delete the two font preloads along with the fonts, or fix the font stack so they earn their place.

One more thing the inventory shows, by absence. There is no <link rel="prefetch"> anywhere, because the App Router does navigation prefetching with its own ?_rsc= fetches instead: 21 of them on a single guide page, 36,941 bytes, which I measured in an earlier post. So the modern Next.js head is much emptier than it looks, and every hint still in it is one you put there yourself, including the three on this site that nobody intended.

All of the above is checkable from outside: the sitemap has the 79 URLs, every head is in the delivered HTML, and the free tools under /tools are a good place to watch a page load with nothing else going on.

Top comments (0)