Nakodo's landing page has no images on it. Not "optimised images", not "lazy-loaded images": no <img> element, no background image, no sprite. Every illustration is an inline SVG, drawn in components, stroked with CSS variables so a dark panel can invert it.

The bill for that arrives as HTML:

$ curl -s -o /dev/null -w '%{size_download}\n' https://nakodo.app/ 596297 $ curl -s -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}\n' https://nakodo.app/ 52023 

596 KB of document, 52 KB on the wire. I went looking for where the 596 KB goes, and about 226 KB of it turns out to be the same path data written twice.

What the document is made of

$ curl -s https://nakodo.app/ > home.html $ grep -o '<svg' home.html | wc -l 78 $ grep -o '<img' home.html | wc -l 0 $ grep -o ' d="' home.html | wc -l 507 

Measuring the pieces in Python rather than with grep gives the split:

Part of the document Bytes
Whole document 596,261
Markup (everything outside <script>) 234,090
Inline SVG elements, 78 of them (one nested in another) 175,177
d attributes, 507 of them, 106 distinct 114,213
React flight payload, one self.__next_f.push 355,799
Two JSON-LD blocks 6,303

Three quarters of the markup is SVG, and half of the SVG is bezier coordinates. That is what hand-drawn line art costs when it lives in the DOM: 106 distinct paths, instanced 507 times, because the repeated ones are leaves, hatching lines and the tally marks on the little charts.

The flight payload has the drawings in it too

The large number in that table is the React payload, and it is not the shape I expected. Decoding it:

payload decoded bytes 336503 payload "d" entries 386 bytes 111656 distinct 108 

386 of the page's 507 paths appear a second time, as JSON, in the script that hydrates the page. 111,656 bytes of coordinates, on top of the 114,213 bytes of coordinates in the markup.

This is not a bug in React, it is the rule I had not thought through. Server components are rendered twice on purpose: once into HTML for the browser to paint, and once into the flight payload, so the client runtime can reconcile what it already has with what it renders next. Anything that crosses into a client component as children has to be in that payload, because a client component is a function React will call again, and React needs the serialised children to pass to it.

The landing page's structure is exactly the shape that triggers it. The guided tour and the scroll reveals are client components, and the drawings are server-rendered children handed to them:

<Showcase count={5} className="mt-12"> ... <Reveal> <ShowcaseStep index={step} note={note} side="left"> {/* server-rendered illustration goes here */} </ShowcaseStep> </Reveal> </Showcase> 

Showcase, ShowcaseStep and Reveal are three small "use client" files: an IntersectionObserver, a note that fades in, a tour that walks the plate. None of them is heavy. But every path inside them is paid for twice in the document, and the remaining 121 live in the parts of the page that no client component wraps.

The fix is not "delete the islands". It is to decide, per island, whether the drawing should be a server-rendered child or an import inside the client component. Imported into the client file, the path data goes into a JS chunk that is cached across every page and never appears in the HTML at all; passed as children, it is inlined into both halves of every response. The trade is that the drawing then ships to anyone who loads the chunk, whether or not that page shows it.

Why this has not been an emergency

Line art compresses absurdly well. 596 KB to 52 KB is 11.5:1, because the document is thousands of repetitions of M, c, l, two decimal places and a space, and because the duplicated payload is a near copy of text that appeared 200 KB earlier, which is exactly what a large brotli window is for.

So the visitor pays 52 KB for a page full of drawings and makes zero image requests. That is a good trade, and it is why none of the usual alarms went off: no Largest Contentful Paint image, no layout shift, no third-party host, no decode cost.

The cost that is real sits on the other side. Those bytes are generated per request on this one page, because the landing page and the pricing page are the two of our 76 URLs that are not static:

$ curl -sI https://nakodo.app/ | grep -i 'cache-control\|x-vercel-cache' cache-control: private, no-cache, no-store, max-age=0, must-revalidate x-vercel-cache: MISS $ curl -sI https://nakodo.app/guides | grep -i 'cache-control\|x-vercel-cache' cache-control: public, max-age=0, must-revalidate x-vercel-cache: HIT 

Both of those two read one request header, because the price is shown in the visitor's own currency and the country comes from the request. That decision is defensible on its own. It is less obviously defensible when the page it makes dynamic is the heaviest document on the site, rendering 507 SVG paths and serialising 386 of them again, for every visitor, with no edge cache in front of it.

Three things I am taking from the measurement, in the order I will act on them:

  1. Count the duplication, not the total. The number that matters is not 596 KB, it is "which paths are in the payload as well as the markup", because that is the half I can remove without changing the design.
  2. Islands have a byte cost in their children. A one-line "use client" wrapper around a drawing is free in JavaScript and expensive in HTML, which is the opposite of the intuition that made me write it.
  3. A dynamic page should be a light page. If a page cannot be cached, every byte in it is paid again on every visit.

Everything above is measurable from outside, so if you want to check my arithmetic the page is at nakodo.app, a static one for contrast is nakodo.app/guides, and the illustrations also show up on how it works.