Nakodo has 76 public pages: a landing page, a pricing page, four calculators, a blog, a glossary and 63 content pages generated from one registry. I wanted to know what one of those pages costs a browser, and the build would not tell me.

This is what next build prints in Next 16.3.7, trimmed to fit:

Route (app) ┌ ƒ / ├ ○ /about ├ ○ /blog ├ /guides/[slug] │ ├ ● /guides/how-to-find-youtube-influencers │ └ ● [+21 more paths] ├ ○ /privacy └ ƒ /pricing ƒ Proxy (Middleware) ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ƒ (Dynamic) server-rendered on demand 

Route, marker, legend. No Size column, no First Load JS column, both of which earlier versions printed for every route. The build output now tells you how each page renders and nothing about what it weighs.

Fine. Measure the deployed site instead, which is the more honest number anyway. I did it three ways, and each way disagreed with the one before it.

One: add up the script tags

Every page's HTML lists its own scripts, so one page at a time:

curl -s https://nakodo.app/pricing \ | grep -o '<script src="[^"]*"[^>]*>' \ | sed 's/.*src="\([^"]*\)".*/\1/' \ | while read -r src; do printf '%7s %s\n' \ "$(curl -s -o /dev/null -w '%{size_download}' \ -H 'Accept-Encoding: br' "https://nakodo.app$src")" "$src" done 

Run that over the 76 URLs in sitemap.xml and take the union: the entire public site is 20 chunk URLs. Fifteen of them are on all 76 pages. The widest page carries 17 and the narrowest 15.

That was the first surprise. For a site with four interactive calculators, a theme switch, a mobile menu and an animated hero, there is almost no per-page JavaScript. The pages differ in HTML, not in code.

bytes (raw) bytes (br) on what it is
317,597 105,906 76 pages posthog-js 1.438.12
228,715 73,147 76 pages react and react-dom
162,077 44,788 76 pages the app router client (81 mentions of prefetch)
112,594 41,343 76 pages core-js polyfills, served noModule
67,183 23,369 76 pages toasts and icons
33,057 9,194 76 pages framework client runtime
30,282 8,342 76 pages Radix dropdown menu
29,059 11,374 76 pages Radix focus scope, aria-hidden, clsx
26,972 10,097 1 page the landing page's client components
26,526 8,984 76 pages Radix tooltip
26,415 10,773 76 pages Radix popper
20,854 7,330 4 pages the calculators
14,445 3,818 76 pages router internals
13,212 4,565 1 page the landing page's animation
11,030 4,526 76 pages the Turbopack runtime
10,262 4,267 76 pages our own site constants
9,042 3,514 76 pages Radix collection helpers
7,486 3,209 68 pages one scroll-reveal component
1,969 884 76 pages the theme switch
652 652 1 page the same scroll-reveal component again

Chunk names are content hashes and change on the next deploy. The sizes are the build that is live as I write.

Summing the fifteen shared ones gives 1,080,253 raw bytes and 354,229 transferred, and that figure is wrong by 112 KB.

Two: the chunk that is never downloaded

Look at how the fourth row is served:

<script src="/_next/static/immutable/chunks/0c0hxoamwjsbw.js" noModule=""></script> 

noModule means "only run this if you cannot do ES modules", so every browser of the last eight years ignores it. The contents say what it is for: forced, isSpecial, scheme, username, password, a URL implementation, a RegExp implementation. That is core-js in its usual clothes, and Next admits to one of these per build in a build-manifest field called polyfillFiles.

You can prove it is never fetched from the browser itself. Load the page, then:

performance.getEntriesByType("resource") .filter((r) => r.name.endsWith(".js")) .map((r) => [r.name.split("/").pop(), r.encodedBodySize]); 

29 JavaScript files come back on our landing page and 0c0hxoamwjsbw.js is not one of them. So the real floor for a current browser is 967,659 raw bytes and 312,886 transferred, and every public page lands within 5 percent of it: the privacy page, which is text and one list, downloads 312,886 bytes, and the landing page downloads 327,548.

Three: what the browser actually ends up with

The same resource timing shows two things the HTML does not.

The first is link prefetching. The landing page requests six chunks that are in no script tag on it, 46,870 transferred bytes, because the router prefetches the routes behind the links in view. That is a feature, it makes the next click instant, and it is also 14 percent more JavaScript than the page's own script tags accounted for.

The second is the analytics library going shopping. posthog-js arrives in the initial bundle at 105,906 bytes and then fetches four more files of itself through our own /alcy path:

file bytes (encoded)
posthog-recorder.js 68,524
dead-clicks-autocapture.js 8,785
web-vitals-with-attribution.js 7,087
exception-autocapture.js 6,784
config.js 690

So the landing page's real JavaScript bill is 374,418 bytes of our own chunks plus 91,900 bytes of PostHog extras: 466,318 transferred bytes, of which analytics is 197,806, or 42 percent. That is more than react, react-dom, the app router client, the framework runtime, the router internals and the Turbopack runtime added together, which come to 176,467.

What those 198 KB are doing

Not much, by our own choice. The whole of our browser-side analytics is one init in src/instrumentation-client.ts plus identify, setPersonProperties, capture and get_distinct_id in three small components. No feature flag is evaluated in the browser. Surveys are switched off in the init call. The recorder that costs 68 KB masks every input by configuration:

session_recording: { maskAllInputs: true }, 

The import that puts all of it on page one is the first line of the client instrumentation file:

import posthog from "posthog-js"; 

Next loads client instrumentation for every route by design, so a static import there is an initial-bundle import on all 76 pages, including the two legal pages that have no product on them. The fix is not clever: await import("posthog-js") behind an idle callback, holding early events in an array until it resolves. It is not done. I am publishing the measurement first, because the measurement is the argument.

There is one more line in that file, and it drags something unexpected along:

import { SITE_URL } from "@/lib/seo/site"; 

One string, for a hostname comparison, and the module graph behind it is now client code: that 10,262-byte chunk of ours exports PUBLISHER, SELF_APP, SITE_URL and organizationIdFor, the constants behind our canonical URLs and structured data. All of it is public information that the pages print anyway, so nothing leaked, but a constants file is server-side only until something in the browser touches it.

Five chunks for one menu

Five of the twenty chunks are Radix primitives: dropdown menu, tooltip, popper, focus scope, collection helper. Together they are 94,798 raw and 34,003 transferred bytes, on all 76 pages, and on a marketing page they do exactly two jobs: the menu button that appears below 768 pixels, and the tooltips on the landing page. Eleven percent of the payload of the glossary is a menu that nobody on a desktop will open.

Three smaller findings came out of the same table.

The four calculators share one 20,854-byte chunk. They are four static pages around one client component with different nouns, and the bundler agreed with that design without being asked.

13,212 bytes of illustration and animation load on the landing page and nowhere else. That is the gust of leaves round the hero, which has no keyframes and no animation library in it, and seeing it isolated on one route is the second time measuring that choice has made me glad of it.

One component is compiled twice. A scroll-reveal wrapper is a 7,486-byte chunk on 68 pages and a 652-byte chunk on the pricing page alone, both exporting the same symbol from the same internal module id. The small copy is not even compressed, because 652 bytes is under the CDN's threshold, which is why its raw and transferred sizes are identical above. Two chunks for one module is an accident of routing, and you only find it by diffing chunk sets between pages.

The thing I had been worrying about instead

The landing page's HTML is 596,361 bytes. I have written about that before: hand-drawn SVG, declared twice for light and dark, no raster images anywhere. It looks like the obvious thing to fix.

It transfers 52,007 bytes. Line art repeats, repeats compress, Brotli eats it.

The JavaScript on the same page, which I had not measured because the build stopped printing it, transfers 466,318. Nine times the document, and nearly half of it is there to count whether you read the document.

Every page in this post is public, and so is the method: open how it works or the pricing page, view source, add up the scripts, then open the console and compare that total with what the browser really fetched. The gap is the interesting part.