I crawled all 38 pages of notifio.app to count internal links, which turned into its own post. While the HTML was sitting in a directory I measured something else: how much of it is actually HTML.
38 pages total 2,142,883 bytes (2.04 MiB) inside <script> 1,315,784 bytes (61.4%) RSC flight payload 1,184,338 bytes (55.3%) JSON-LD 106,700 bytes ( 5.0%) everything else 24,746 bytes ( 1.2%) Sixty one percent. The thing a browser parses as markup is the minority of the document on every single page.
Why: your sentences ship twice
This is a Next.js App Router site, so each response carries the React Server Components payload: the serialised tree the client runtime uses to hydrate, and to render a client-side navigation without a round trip. It arrives as a sequence of self.__next_f.push(...) calls at the end of the body. Our pages have between 4 and 22 of them.
The payload is not a summary of the page. It contains the text. Pick any sentence off a page and count it in the source:
curl -s https://notifio.app/alerts/kamernet | grep -c "Kamernet lets you save a search" # 2 Two hits, 28 KB apart in the document: one in the markup, one inside a <script>. That holds for every server-rendered page on the site: /terms, /help, the homepage, all of them.
So the uncompressed numbers are easy to explain. Visible text on a site page is about 5 KB. The page is 55 KB. The text is in there twice and the second copy is wrapped in escaped JSON, which is bulkier than the markup it mirrors.
Then compress it, because that is what ships
Duplicated strings are the single easiest thing in the world for a compressor. So the raw number is close to meaningless on its own, and the honest measurement is to do it twice, with and without the payload:
38 pages raw gzip as served 2,142,883 390,225 (18.2%) flight removed 958,545 241,331 cost of flight 1,184,338 148,894 The flight payload is 55.3% of the raw bytes and 38.2% of the compressed bytes. Per page:
| Page | raw | gzip | gzip without flight | cost |
|---|---|---|---|---|
| /alerts/kamernet | 55.0 KB | 9.8 KB | 6.2 KB | 3.6 KB |
| / | 91.5 KB | 14.7 KB | 8.7 KB | 6.0 KB |
| /pricing | 25.9 KB | 5.7 KB | 4.0 KB | 1.7 KB |
So shipping every sentence twice costs about 3.6 KB per page view on a content page, and 6 KB on the homepage. That is not nothing on a slow connection, and it is also not the problem anybody should solve first. It buys hydration and instant client-side navigation, and the only real lever on it is having less tree, not having less payload.
Worth saying plainly because "61% of our HTML is JavaScript" is the kind of line that gets written as an indictment of a framework. Measured where it actually matters, over the wire, it is 38%, and it is the price of a feature we use.
The one page that does not duplicate
/pricing is the outlier in every column: 4 flight pushes against 17 on a site page, 8.3 KB of payload, and 0.5 KB of JSON-LD. Its feature list is also the one piece of visible text on the site that appears in the HTML exactly once:
curl -s https://notifio.app/pricing | grep -c "Monitor unlimited rental sites" # 1 Because that string does not live in a server component. It lives in a constant inside PricingClient.tsx, a client component:
const FEATURES = [ "Monitor unlimited rental sites", // ... ]; The server renders it to markup once, and the second copy is not in the HTML at all, it is in a JavaScript chunk. Which is a genuinely better deal for a page people load repeatedly, since the chunk is cached across routes while the payload is re-sent with every HTML response. It is also how I would describe the entire trade: server components put your text in every response, client components put it in a file that downloads once.
(The claim in that constant is wrong, incidentally. There is a cap of 15, and I audited every page that says otherwise.)
And then the number that matters
Having spent an hour on 3.6 KB, I measured the script tags that point at files rather than contain them. Every one of the 38 pages references the same eight chunks:
raw gzip chunk 440.7 KB 137.9 KB 1iqydoyh4vo34.js (React runtime) 151.5 KB 49.9 KB 2whrfygnf_0tr.js (framer-motion) 150.0 KB 40.3 KB 1aszb2wwd29om.js 110.0 KB 38.5 KB 0cz1d0mv5g_q7.js 51.9 KB 11.0 KB 435jj7mfrrt7n.js 50.9 KB 11.9 KB 057ltze_q3l99.js 19.0 KB 6.7 KB 2v_6snws2bthg.js 10.3 KB 4.1 KB turbopack-....js ------------------- 984.4 KB 300.3 KB on every page 300 KB gzipped of JavaScript on every page, against 9.8 KB of HTML. The duplication I set out to measure is 1.2% of what the browser downloads.
I identified the 151 KB chunk by grepping it for motionValue, scrollYProgress and whileHover. It is framer-motion, and it is referenced by all 38 pages, including /terms and /privacy, which are walls of legal text.
What does it do on those pages? There are exactly two client components in that tree. The nav, which adds a border and a blur when you scroll past 20 pixels:
const [scrolled, setScrolled] = useState(false); useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 20); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); That one is plain React and costs nothing much. And the footer, which exists as a client component for this:
const { scrollYProgress } = useScroll({ target: footerRef, offset: ["start end", "end end"] }); const y = useTransform(scrollYProgress, [0, 0.75], ["100%", "0%"]); // ... <motion.span style={{ y, fontSize: "clamp(5rem, 16vw, 16rem)" }} aria-hidden="true"> Notifio </motion.span> A giant wordmark that rises into the footer as you reach the bottom of the page. It is aria-hidden, purely decorative, and I like it. It is also the reason an animation library is on the critical path of a privacy policy.
That same component is the one whose client-ness already forced a hand-picked five of our fifteen site pages into the footer, because importing the full catalogue into a client component would ship the whole catalogue to the browser. One decorative animation has now cost us an internal link graph and 50 KB gzipped on every route.
The fix is not to delete the wordmark, it is to stop needing JavaScript for it. A scroll-driven CSS animation does exactly this effect with animation-timeline: view(), with a prefers-reduced-motion block that I should have had anyway, and that turns the footer back into a server component.
What I would tell myself before starting
- Measure raw and compressed, always. The ranking changes. Raw bytes said the flight payload was the story at 55%; compressed bytes said it was 38% and still not the thing to fix.
- Measure what the browser fetches, not just the document. I nearly published the 61% figure on its own. The document is 3% of the page weight on this site.
- A decorative animation is an architectural decision. Ours set the bundle floor and the internal link graph. Neither of those is what you are thinking about while you tune a transform.
Go and look
Everything here is reproducible with curl and gzip:
u=https://notifio.app/alerts/kamernet curl -s "$u" > /tmp/p.html wc -c < /tmp/p.html # raw gzip -9c /tmp/p.html | wc -c # gzipped perl -0pe 's/<script>self\.__next_f.*?<\/script>//gs' /tmp/p.html > /tmp/q.html gzip -9c /tmp/q.html | wc -c # gzipped, no flight payload grep -c "Kamernet lets you save a search" /tmp/p.html # 2 Then do the same for /pricing and watch the last number come back 1.



