DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A 436-word static page on our site downloads 263 KB of JavaScript, and a quarter of it is a realtime client we never open

munchable.app/is-yogurt-ok-for-sibo is one of 461 pages on our site that answer a single question about a single ingredient. It is prerendered at build time, it has no form, no chart, no modal and no state. The route file says export const dynamic = 'error', which means the build fails if anything on it tries to read a request.

I measured what a browser actually fetches for it. The method is three lines of Python: request the HTML, pull every <script src> out of it, then request each one twice, once with Accept-Encoding: identity and once with br.

Here is the page:

HTML                        39,482 bytes      8,437 over the wire (brotli)
visible text                 2,512 bytes      436 words
inline <script>             24,571 bytes      of which 22,821 is the RSC payload
JSON-LD                      1,547 bytes      two blocks
Enter fullscreen mode Exit fullscreen mode

And here is the JavaScript. Eleven files, no images, no fonts in this list:

  over wire    raw
   72,353   227,532   react-dom and the scheduler
   69,019   259,245   @supabase/ssr, which is to say supabase-js
   41,343   112,594
   39,411   140,903
   13,421    54,650
    9,832    32,633
    9,670    44,415
    7,461    20,689
    4,336    10,580   the turbopack runtime
    1,492     3,377
      895     1,936
  -------  --------
  269,233   908,554
Enter fullscreen mode Exit fullscreen mode

263 KB over the wire, 887 KB parsed, for 436 words. The HTML is 3 per cent of the transfer.

What is in the second line

I looked for marker symbols inside each chunk. The 259 KB one contains GoTrueClient and RealtimeClient.

RealtimeClient is the WebSocket client for subscribing to Postgres changes. Here is every use of it in our web app:

$ grep -rn "\.channel(\|postgres_changes\|supabase\.from(\|\.storage\." apps/web
$
Enter fullscreen mode Exit fullscreen mode

Nothing. No channel, no row subscription, no storage call, no table read through the browser client at all. Database reads on this site happen on the server through an ORM, and the browser client exists for exactly one job: knowing whether there is a session.

That is not a Supabase complaint. createBrowserClient from @supabase/ssr returns a full client, auth plus postgrest plus storage plus realtime, because that is what the object is. Nothing on our side tree-shakes a method you never call off a class you do instantiate.

Why it is on a static page at all

The chain is four steps and every one of them looks reasonable on its own.

app/layout.tsx          every page in the site
  components/Header     the site header
    AuthControls        'use client'
      lib/use-auth      createBrowserClient(...)
Enter fullscreen mode Exit fullscreen mode

The header shows a Sign in button when you are signed out and an Account menu with your email address when you are signed in. To do that it needs the session, and AuthControls is therefore a client component, and it sits in the root layout so that it is on every page, which includes 461 pages that were rendered months before you arrived.

This is the structural part, and it is worth saying plainly because it is not a bug anyone introduced. A statically prerendered page cannot know who you are. If its chrome is personalised, the personalisation has to happen in the browser, and that means shipping enough code to resolve a session. The 259 KB is the price of a header that knows your name on a page that was built before you existed.

Four ways out, honestly costed

Lazy load the auth client. Render the signed-out header as a plain link, and only import the auth client on interaction or after idle. This is the real fix. It takes 69 KB over the wire off 461 pages, and it costs a visible change for signed-in readers: the header says Sign in for a moment before it corrects itself, on the pages where almost nobody is signed in anyway.

Talk to the auth REST API with fetch. Perfectly possible, and you immediately own token refresh, expiry, storage and multi-tab synchronisation. That is a sensible trade for an app whose whole job is auth, and a terrible one for a header.

Make the content pages dynamic and read the cookie on the server. This converts 461 static files into 461 server renders to save a download. Wrong direction.

Decide it does not matter. The chunks are shared and immutable, so a reader who visits a second page pays nothing. This is the argument I find least convincing, because the traffic these pages exist for arrives cold from a search result, looks at one answer, and leaves. The cache argument is weakest exactly where the traffic is.

The other number in the first table

22,821 bytes of inline script, on a page whose visible text is 2,512 bytes. That is the React flight payload: the serialised component tree, which contains the page's own words a second time, in a form the HTML parser ignores and the React runtime reads. It compresses well, because of course it does, it is a near-duplicate of text that is already in the document.

I am not going to pretend that is a finding. It is how the App Router works, and the price is small after compression. It is just striking, when you put it next to a 436-word page, how much of what you ship is machinery for the words rather than the words.

What I would tell you to do

Measure your own. Not with a score out of 100, with a list: the files, their sizes, and one named reason each file is in the response. Ours now has eleven rows, and I can name what is inside three of them. The other eight are the next afternoon of work.

If you want to check my numbers, the page is right there, the network tab sorted by transfer size takes ten seconds, and the other 460 are built from the same template, so they will all tell you the same story.

Top comments (0)