Skip to content
Engineering10 min read

Your content is probably in your JavaScript bundle

We found 28 kilobytes of gzipped content sitting in the shared bundle of every page on a site — the whole service data layer, full body copy included, shipped to every visitor so a navigation dropdown could render twelve names. Nothing had gone wrong. Everything compiled, every test passed, and the site had been slower than it needed to be for months.

How the content gets there

In a modern React framework, marking a component as client-side does more than enable state and event handlers. It moves that component, and its entire import graph, into the browser bundle. That second part is the one that surprises people.

If a client component imports a module, the bundler has to include that module. It does not know that you only wanted four fields from each entry, or that the remaining fields are long-form copy already rendered into the HTML by a server component elsewhere. It sees an import, so it ships the module.

This is why the problem concentrates in navigation. A header is the most likely component to be interactive — dropdowns, mobile menus, scroll behaviour — and it is also the most likely to want a list of your services or categories. It is also, fatally, mounted in the root layout, so whatever it drags in lands on every route in the application.

Why nothing tells you

There is no error condition here. The code is correct, the types are correct, and the page renders exactly as intended. The only symptom is a number in a report nobody generates, which is why this survives code review indefinitely.

Framework build output does not help much either. It reports first-load JavaScript per route, but when the bloat is in the shared layout chunk, every route reports the same inflated figure. A uniformly high number reads as a framework baseline rather than as a bug, and teams conclude their floor is simply higher than they would like.

That misdiagnosis matters because it changes what people do next. Teams conclude the framework is heavy and start looking at rendering strategies or considering a rewrite, when the actual problem is one import statement.

A uniformly high first-load figure across every route is a signal, not a baseline. Something in your shared layout is heavier than it should be.

Finding it in your own build

You do not need a bundle analyser plugin for the first pass. Two techniques will find most of it.

  • Diff a minimal route against your heaviest one

    List the script tags on a page with almost no interactivity — a privacy policy, a glossary entry — and on your homepage. The intersection is your shared baseline. If that intersection accounts for nearly all of your bundle, the problem is in the layout path and no amount of page-level optimisation will help.

  • Grep the chunks for your own content

    Take your largest shared chunks and search them for a distinctive string from your data — a service name, a product title, a phrase from body copy. If you find it, that content is being shipped as JavaScript. This takes about a minute and is the single most direct evidence you can get.

  • List every client component that imports a data module

    One grep across your source for the client directive, cross-referenced against imports from your content modules. In our audit that produced three files, and one of them accounted for nearly all of the waste.

  • Check whether the import is type-only

    Type imports are erased entirely at compile time and cost nothing at runtime. A component that only needs a shape rather than values should say so explicitly, and marking it makes the intent unambiguous to the next reader.

The fix is a props boundary

The correct pattern is to keep the data module on the server and hand the client component only what it renders. A server component imports the module, maps it to the minimal shape, and passes that down. The heavy module never crosses the boundary.

In practice this means writing a small derivation next to your data — a function returning just the fields the navigation needs — and having the layout call it. The client component then declares a props type describing exactly what it consumes, which has a pleasant secondary effect: the boundary becomes self-documenting, and the next person to add a field has to think about whether it belongs on the client.

The saving is proportional to how much copy your content modules carry, which for a content-heavy marketing site is a lot. Ours held full funnel copy, FAQs and process steps for every service. Rendering that to HTML is the whole point; shipping it as JavaScript to render a dropdown is not.

Then stop it coming back

A fix that relies on everyone remembering is not a fix. This is a mistake that looks completely reasonable in a pull request — someone needs a list of categories, imports the module, ships it — and reviewers have no signal that anything is wrong.

So we added a check to the build. It walks the source, finds every file carrying the client directive, and fails if any of them value-imports one of our content modules, with an error explaining the props alternative. It is roughly twenty lines and it makes a whole class of silent regression impossible.

That is the general lesson rather than anything specific to one framework. Performance problems that produce no error will always come back, because nothing in the normal development loop surfaces them. If you fix one, spend the extra twenty minutes making it detectable — otherwise you will find the same kilobytes again next year, and be equally surprised.

We do this for a living

If you'd rather not build this yourself, these are the services where it lives.

Rather not DIY

We'll run this on your account.

A free 30-minute teardown of your funnel, ads, and analytics. You keep the findings whether or not we work together.