DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A text page on our site ships 1 MB of JavaScript, and 45 per cent of it is the error reporter

Notifio is a desktop app that watches rental search pages, and its marketing site has one page per supported site: notifio.app/alerts/kamernet and fourteen others. These pages are the purest static content we own. They are force-static, their content is a TypeScript array in the same repository, and the only things a visitor can do on them are read and click a link.

Here is what one of them costs:

HTML        56,342 bytes   (10,584 over the wire)
CSS         39,976 bytes   ( 8,348 over the wire)
JavaScript 1,007,990 bytes ( 318,056 over the wire)
Enter fullscreen mode Exit fullscreen mode

Eight script tags, one megabyte of JavaScript, for a page whose interactive surface is a mobile menu button. I have measured a 436-word static page on a sister site that downloads 263 KB and thought that was bad. This is four times worse, so I went chunk by chunk.

The eight chunks, identified

You cannot tell what a Turbopack chunk is from its name, so I downloaded all eight and grepped each one for strings only one library would have.

Bytes Over the wire What is in it
451,254 145,116 Sentry browser SDK (226 matches for sentry)
155,160 53,274 framer-motion (MotionConfig, transformPerspective, layoutDependency) plus React internals
153,598 42,423 the Next.js client runtime (42 matches for flight)
112,594 41,343 React, minified past recognition
53,187 11,713 Next.js client code
52,151 12,710 app code and chunk glue
19,467 7,149 page chunk
10,579 4,328 the Turbopack runtime

Two of those eight are 60 per cent of the total, and neither of them is on the page because the page needs it.

451 KB of error monitoring, from nine lines

This is the entire cause of the largest chunk:

import * as Sentry from "@sentry/nextjs";

// Browser Sentry init. Next.js loads this natively (no build plugin required).
Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,
});

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
Enter fullscreen mode Exit fullscreen mode

src/instrumentation-client.ts, nine lines including the comment. Next.js picks that file up automatically and loads it on every route, so @sentry/nextjs 10.67.0 is on all 38 of our URLs. I checked seven of them by hand and the same chunk hash is in every one.

To be fair to it, it is not as bad as it could be. I grepped for the heavy optional pieces and session replay is genuinely absent: no rrweb, no replayIntegration, no feedback widget. What is in there is the core SDK plus browser tracing, which is pulled in because tracesSampleRate: 0.1 asks for it, so one in ten page views also sends a performance trace.

I am not going to pretend error monitoring is a luxury. It is how I find out that something broke for a visitor in one of the 24 currencies we price in, in a country I have never tested from. The question is whether it has to be on the critical path of a page that is a wall of text.

Count what can actually throw in a visitor's browser on these 38 pages: the mobile menu toggle, the FAQ accordion on the help page, the contact form under it, and the currency switch on the pricing page. Four surfaces. The other 34 pages ship 145 KB over the wire to watch static text not fail.

155 KB for one animated word

The second chunk is the one that annoys me more, because it is entirely our own doing. Our footer is a client component, and this is why:

"use client";

import Link from "next/link";
import { motion, useScroll, useTransform } from "framer-motion";
import { useEffect, useRef } from "react";

function FooterWordmark() {
  // ...
  const { scrollYProgress } = useScroll({ target: footerRef, offset: ["start end", "end end"] });
  const y = useTransform(scrollYProgress, [0, 0.75], ["100%", "0%"]);
Enter fullscreen mode Exit fullscreen mode

The wordmark at the bottom of the page slides up into view as you scroll into the footer. One <motion.span>. That is the only framer-motion on the page, and framer-motion appears in exactly five files in the repository: the footer, and four animated demos that are imported by app/page.tsx and nothing else. The home page loads ten script chunks; every other page loads eight, and the two extra ones are the demos.

So 37 pages carry an animation library to move one word, and the same footer already costs 139 KB of HTML on every page because of the line art drawn into it. That footer is now comfortably the most expensive thing on the site, and nobody has ever mentioned it in a support email.

A scroll-linked slide of one element is animation-timeline: view() in CSS, or an IntersectionObserver and a transform, and either version is under thirty lines. Doing it that way takes the footer back to a server component, which also removes a client boundary from every page on the site.

What the page would be without those two

The arithmetic, over the wire:

318,056  today
-145,116  no client Sentry
- 53,274  no framer-motion chunk
=119,666  React, the Next client runtime and our own code
Enter fullscreen mode Exit fullscreen mode

That is a 62 per cent cut on a page where the saving buys nothing back except a word that moves and a crash report for text that cannot crash. And 119 KB is roughly the floor for an App Router page with a client component in it, which is a separate conversation about whether a mobile menu needs to be React at all.

What I am doing about it

The footer is the easy one and it goes first: CSS scroll-driven animation, drop "use client", drop the framer-motion import. One chunk leaves the site.

Sentry is the interesting one, and I am not deleting it. The options I weighed:

  1. Leave it. Defensible. 145 KB on a well-cached CDN path with a dark static page is not what loses a sale.
  2. Tree-shake the tracing out. @sentry/nextjs respects __SENTRY_DEBUG__ and __SENTRY_TRACING__ as build-time constants, and turning tracing off would take a real bite out of 451 KB. It also deletes the performance data, which is the half of Sentry I use least, so this one is on the table.
  3. Load it off the critical path. Replace the eager top-level import with a dynamic one inside requestIdleCallback, so the SDK arrives after the page is usable. The honest cost is that errors thrown during the first paint go unreported, which on a static page is close to no cost, and on the pricing page is exactly the error I would most want. That asymmetry is the whole decision.

I am going with 3 for the static routes and keeping the eager init where a visitor can press something that spends money. The implementation is less tidy than one global file, which is precisely why the one global file existed.

If you want to measure your own: curl the page, pull out every <script src>, request each one twice, once with Accept-Encoding: br, gzip and once without, and add the two columns up. The whole audit was four lines of shell, which is a poor excuse for having never run it before.

The pages in question are the alerts hub and the fifteen site pages under it, including Rightmove, SpareRoom and Idealista. The download page and the pricing page are the two that genuinely need their JavaScript, and the guides are the ones that most clearly do not.

Top comments (0)