Skip to content

JavaScript Cost

Every kilobyte of JavaScript costs more than a kilobyte of anything else. An image is downloaded and decoded mostly off to the side. A script is downloaded, then parsed, compiled and run on the main thread, and while it runs the page can't answer a tap. The median mobile page now ships about 650 KB of it, compressed, and about 250 KB of what it loads never runs.

Reload, then tap both buttons as soon as you see them. Try each device.

Right

Canvas tote

$38

50 KB · works after 175ms
 
Lean page
Wrong

Canvas tote

$38

600 KB · works after 2,100ms
 
A typical page today

Both pages look ready at once, because the HTML arrived. But the buttons only work once their script has booted. On a fast laptop the gap is short. On a mid-range phone, which runs script about 3.5 times slower than a current iPhone, the typical page ignores your taps for two seconds; on a budget phone, for five. A page that looks ready but ignores taps feels broken, not slow.

Ship the least JavaScript that makes the first screen work. Load the rest when someone is about to need it.

Make the first screen work early

  • Hydrate less. Content pages don't need every component to be interactive: render on the server and add script only where something responds, as islands (Astro) or Server Components (Next.js) do.
  • Make key controls work before script. A real <a href>, a <form action>, <details> and <dialog> work the moment the HTML arrives, so a slow boot makes them slower instead of broken.
  • Keep the first bundle to the shell and the current route. Every modern router splits by route; check that yours does.

Load heavy things on intent

Editors, charts, maps, video players, date pickers and payment SDKs are often hundreds of kilobytes each and used by a fraction of visitors. Load them when someone shows intent, or when they scroll near.

JavaScript on first load
40 KB
Click to editor ready
–

Loading everything upfront makes the editor open instantly but puts 340 KB in front of every visitor. Loading on intent ships 40 KB, starts fetching the editor when the pointer reaches the box, and by the click most of the wait is gone. Click without hovering (with the keyboard, say) and you see the short skeleton instead. Google Docs saved 500 KB by loading its share dialog this way.

Watch the third parties

Analytics, tag managers, chat widgets and A/B tools account for over half of all script execution on the web. A YouTube embed alone averages about 6 seconds of main-thread work. Load them after the page is interactive or on first interaction, replace widgets with a facade that loads the real thing on click, and remove the ones nobody looks at.

Set a budget and enforce it

For a phone to load a page in about 3 seconds, Alex Russell's 2026 numbers allow roughly 300 KB of compressed JavaScript on the critical path. A good default is 150 KB for content pages and 300 KB for app shells, no single chunk over 100 KB, and a check in CI that fails the build when a change goes over. Set the limit at today's size and lower it with every win.

Usage

app.tsx
import { lazy, Suspense, useState } from "react";

// One promise, cached by the module system. Declare outside components.
const loadEditor = () => import("./RichEditor");
const RichEditor = lazy(loadEditor);

function CommentBox() {
  const [open, setOpen] = useState(false);
  if (open) {
    return (
      <Suspense fallback={<EditorSkeleton />}>
        <RichEditor autoFocus />
      </Suspense>
    );
  }
  return (
    <button
      onPointerEnter={loadEditor} // warm it before the click
      onFocus={loadEditor}        // keyboard users too
      onClick={() => setOpen(true)}
    >
      Write a comment
    </button>
  );
}
// .size-limit.json — fails CI when a bundle grows past its budget.
[
  { "path": "dist/assets/index-*.js", "limit": "150 KB" },
  { "path": "dist/assets/vendor-*.js", "limit": "100 KB" }
]

// package.json
// "scripts": { "size": "size-limit" }

To see what you ship, open DevTools' Coverage panel and reload: red is code that downloaded but never ran, the first candidates for lazy loading. A bundle analyzer (npx vite-bundle-visualizer, npx next analyze) shows what's inside each chunk; look for duplicate libraries, whole utility libraries imported for one function, and polyfills modern browsers don't need. Then measure on a real mid-range Android: as Measuring Performance explains, a laptop hides almost all of this.

Resources

Search concepts

Search for a command to run...