Reader performance budget

Embed
Preview of the card
<a href="https://forumulate.com/guides/reader-performance"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/62bbde09-82e3-4bc1-ad97-041a9dc72863/social-card.svg?v=3e7ae33eec8b" alt="Reader performance budget" width="600" height="315"></a>

How much JavaScript a reader page loads, what we have committed it will not exceed, and how that commitment is enforced. This page exists because a customer asked for either a documentation page under 250 KB of compressed JavaScript or a published target they could hold us to, and the target was only written down where they could not see it (Crossly F-22).

A correction, first

An earlier version of this page said an API reference page weighed 220.8 KB and was inside the 250 KB goal. That was wrong, and wrong in our favour.

The reading was taken in a browser with part of the cache already warm: 10 of the page's 24 scripts reported a transfer size of zero, so their bytes were counted as nothing. Measured cache-independently, the same page on the same build was 256.6 KB — over the goal.

What gave it away was arithmetic, not suspicion: we had quoted 271.7 KB for a prose guide and 220.8 KB for a reference page, and those two pages load the identical 24 chunks. One chunk set cannot weigh two different amounts.

The numbers below are measured two independent ways that agree.

The goal, and where we are

The goal is 250 KB of compressed first-party JavaScript on a documentation page. It is met on both honest readings.

A documentation page loads 164.4 KB across 16 scripts on a cold first view — everything it fetches before it decides anything about its own content. It was 176.9 KB on the morning of 2026-10-15 (see below), 174.7 KB on 2026-10-08, 197.2 KB before the cut described below, 353.0 KB a fortnight before that, and 592 KB when the goal was set.

A page full of code blocks fetches more, because it then asks for the syntax highlighter, and an API reference also loads its request playground. Measured in a browser on the deployed build, cache disabled, first party only:

Page

First-party JS

Scripts

/reference/… (an API reference)

234.1 KB

24

Measured a second way — fetching every script the page's HTML references, asking the server for gzip and summing what comes back over the wire — 217.2 KB across 23 scripts. The browser's extra script is one reached by a dynamic import, which is why its figure is the higher of the two and the one we hold ourselves to.

Of that, about 75 KB is the framework runtime (React, the Next router) and is not ours to remove without changing what the reader is built on.

What each route may not exceed

Every figure is compressed bytes over the wire, and every ceiling is a maximum that may only ever be lowered. Lower it whenever a build comes in under it; never raise one without saying in the same change what was added and why it was worth it.

Reader route

Measured

Ceiling

Max scripts

Site index and search, on your own domain

128.8 KB

133 KB

9

Documentation space index

164.2 KB

165 KB

16

Documentation page (your domain and ours)

164.4 KB

168 KB

17

Blog post

162.8 KB

167 KB

16

Forum thread

190.4 KB

196 KB

14

Measured 2026-10-15. That morning four of these routes were over their ceilings — the documentation space index at 185.9 KB — after a day of new reader features; the section below on what we removed says how they came back under without dropping any of them.

A documentation page served from your own domain is the documentation-page row, because a site mount rewrites yourdomain.com/docs/<page> onto the same route our own path serves. The site row is the index and its search, and it is not an answer to "how heavy is a docs page".

The forum thread was the one reader route well above the goal: 357.6 KB across 22 scripts until 2026-10-12. About 62 KB of that was a Markdown parser and renderer, with its entity and link-detection tables and a schema validation library. We had blamed it on the thread rendering its posts in the browser. That was wrong too: every post you see on load was already rendered on the server. What pulled the renderer onto first paint was the region that shows replies arriving while the page is open, which draws nothing on load but imported the renderer anyway, and the translate button. Both now fetch it only when it is needed: when a reply arrives live, or when a translation comes back. Writing a reply or editing one already loaded it on demand. The thread is now 190.4 KB across 13 scripts, and readers see no difference.

Why a browser reads more than the table

The table is measured from the build manifests: it counts what a cold first view loads eagerly. A browser also fetches chunks reached only by a dynamic import, which the manifest cannot attribute to a route because whether they load depends on the page's content:

  • the syntax highlighter (55.3 KB) loads when a page contains a code block, so an API reference page really does download it;

  • the request playground, language tabs and SDK panel (4.2 KB together) load only on a page with an API reference in it;

  • Mermaid (109–144 KB) and KaTeX (74 KB) load only if a diagram or a formula is used;

  • the error-reporting SDK (70.5 KB) loads only if something goes wrong — see below.

One thing we stopped counting

The table used to include a 38.5 KB polyfill bundle that no browser downloads. The framework emits it as a nomodule script — a tag that exists so that browsers which understand modern JavaScript skip it entirely — and every browser that can run this app skips it.

Removing it from the measurement is a correction, not a saving, and it is called out because a number that is wrong in our favour is no better than one that is wrong in yours.

How it is enforced

node apps/web/scripts/js-budget.mjs runs after every web build in our CI and exits non-zero if any route is over its ceiling, so a change that makes the reader heavier fails before it ships. A second check keeps the budget file itself honest without needing a build, and a third — scripts/verify-live.mjs — measures the deployed page from outside and fails if it is over 250 KB.

This is worth saying plainly: for two weeks the budget file existed and nothing ran the check, while the file claimed CI did. It does now.

What we have removed

Each cut measured against the build before it, not estimated:

  • 2026-10-15: what a page might draw is fetched only when it draws it. The reader's dropdowns are now plain markup that the page wires up when one is on it, instead of a component every page downloaded; the dropdown's markup is in the page once instead of three times; the subscribe menu, the roadmap vote, the reader's-time-zone dates and the form-block handling (with its captcha) load on a page that has one; the reader's English for custom spaces, a site's own pages and the signup page left the file every page carries (128 keys down to 29); and a documentation page stopped carrying the FAQ list's code for a one-line "contact us". The documentation space index went from 185.9 KB to 164.2 KB, the blog post from 181.2 KB to 162.8 KB, the documentation page from 176.9 KB to 164.4 KB;

  • the error-reporting SDK is no longer loaded eagerly (70.5 KB). It was imported statically into a chunk every route shares — the largest single thing on a documentation page, bigger than React — and our own budget file had it recorded as 17.8 KB from a per-package reading that missed most of it. Two listeners, about a kilobyte, now catch errors from the first moment and the SDK is fetched the first time there is something to report; an error that happens before it arrives is buffered and replayed into it. If the SDK itself cannot be fetched, the event goes straight to the ingest endpoint instead — because the one thing that could not be relied on during the outage this reporting exists for was fetching another chunk;

  • the reader stopped reaching a shared internal package through its module barrel (68 KB). Two one-line helpers — "is this consent category opt-in?" and "what is this page's Markdown address?" — were imported in a way that forced the bundler to keep the entire package, including every space-type definition, our connector catalogue and a schema validation library, on every documentation page;

  • error reporting ships only the half we switched on (23.4 KB). Performance tracing and the debug logger were compiled in and then disabled at runtime; they are now excluded at build time, which we verified against the deployed chunk rather than assuming: no tracing integration, no web-vitals collector, no session-replay recorder in it;

  • the request playground, language tabs and SDK panel are fetched when a page turns out to have an API reference in it, instead of on every page (4.2 KB), and the extension block view likewise;

  • earlier in this effort: the comment-permission check stopped pulling the Markdown parser and the block schema into a documentation page (114 KB); the syntax highlighter became conditional (56 KB); the interface translations were partitioned, so a reader carries a 7.3 KB subset instead of 67 KB of console copy; heading anchors moved to the server.

What we have not removed, and why

  • The Atlas sign-in SDK, 16.1 KB. It is loaded on our own domain so that staff reading their own documentation get the editing bar. It is already absent on every customer domain.

  • The syntax highlighter, 55.3 KB. It could be cut to a few kilobytes by loading only the grammars a page declares. The reason we have not is that language names have aliases — js, sh, py — and resolving them ourselves is a change whose failure mode is a code block that silently stops being coloured. On the page you read, a regression is worse than 50 KB.

  • The confirmation-dialog provider, 3.4 KB. It is mounted at the root for every page, and a forum thread genuinely uses it. Moving it would mean auditing every surface for a dialog that silently does nothing.

If this matters to you

Tell us which page and which number. Every figure here is reproducible from outside: a browser with the cache disabled, summing the transfer size of every response whose type is Script — and if you use the browser's resource timings rather than the network panel, read encodedBodySize and not transferSize, because the second one reports zero for anything already cached. That is the mistake behind the figure we had to correct at the top of this page.

The Forumulate teamUpdated