DEV Community

Melisa Wong
Melisa Wong

Posted on

The 83 MB JavaScript bundle was not a dependency problem

I ran ls -lh on a production build last week and got this:

-rw-r--r--  1 user  staff   3.8K  index-FYxpahNp.css
-rw-r--r--  1 user  staff    79M  index-RqoFan-G.js
Enter fullscreen mode Exit fullscreen mode

Seventy-nine megabytes of JavaScript. One file. One line, 13,308,122 characters long.

The stylesheet, for comparison, is 3.8 KB. The CSS is a rounding error next to the script.

This is a React app packaged with Capacitor, so there is no CDN and no lazy route to hide behind. That 79 MB file is inside the app binary, read from local storage on every cold start, and parsed before the first screen paints.

This post is the audit I should have run two months earlier, the three reasons the usual tooling missed it, and the split that brought the entry chunk down to something reasonable.

How a bundle gets to 79 MB without anyone noticing

Nobody adds 79 MB of JavaScript on purpose. You add a data file.

This app is a content product. Every record it ships carries two text sections — one shown under the main illustration, one in a separate panel — and each section exists in several variants so a user does not see the same paragraph twice. Every string is localized into ten languages, with the source text kept alongside.

Text meant to be read is not redundant, so it compounds fast. A few hundred records multiplied across sections, variants, and languages is already tens of thousands of strings — and all of it was living in a source module.

The data lived in a module like this:

// content.ts
const db: ContentDB = {};

db["group-male-01"] = {
  sectionA: { A: { en: "...", es: "...", /* more */ }, B: { /* ... */ }, /* more variants */ },
  sectionB: { A: { /* ... */ }, /* ... */ },
};

db["group-male-02"] = { /* ... */ };
// ... hundreds more assignments
export default db;
Enter fullscreen mode Exit fullscreen mode

And it was imported the normal way:

import db from './data/content';
Enter fullscreen mode Exit fullscreen mode

That is the whole story. A single TypeScript data file that had grown well past 50 MB became a 79 MB minified chunk, and the build reported success.

Two details matter more than they look:

It is an imperative build, not a literal. db[key] = {...} is a side effect. Bundlers cannot tree-shake it, cannot see the shape of the object, and cannot split it. You get one evaluation unit, all or nothing.

It is imported by the entry module. Even if the data were a plain object literal, a static import from main.tsx puts it in whatever chunk main.tsx lives in. In this build there is exactly one JS file, so "the entry chunk" and "all of the app's content" are the same artifact.

Why the bundle analyzer was quiet

I ran the analyzer. It showed a clean report: React, React DOM, a router, a state library, a date library. Nothing over 200 KB. Total dependency weight looked perfectly healthy.

The analyzer was not lying. It lists packages. My weight was in ./src/data, which is my own code, and own code does not get a row unless it is a big named import. The tool answered the question I asked ("which packages are heavy?") instead of the question I had ("which bytes are large?").

Three checks would have caught it in seconds, and none of them are on by default:

# 1. raw size of every emitted chunk, sorted
ls -lS dist/assets/*.js | head

# 2. gzip size, because raw size alone scares people about the wrong things
for f in dist/assets/*.js; do
  printf "%8s raw  %8s gz  %s\n" \
    "$(wc -c < "$f")" "$(gzip -9 -c "$f" | wc -c)" "$f"
done

# 3. the tell: a single line longer than 100 KB is inlined data, not logic
awk '{ if (length($0) > 100000) print FILENAME": line "NR" = "length($0)" chars" }' dist/assets/*.js
Enter fullscreen mode Exit fullscreen mode

The third one is the one I now trust most. Minified code is long-lined but bounded. A minified data literal produces one absurd line, and in this build that line is 13.3 MB. If you see that, stop looking at your dependencies.

Gzip does not rescue you

Compressed, the chunk goes from 83,249,461 bytes to 31,707,388 — a 2.62× reduction. That is worse than typical for code (usually 3–4×), because prose in ten languages is not as redundant as machine-generated identifiers.

So even after compression: 30 MB. And compression only helps the transfer, not the parse. The engine still has to parse and evaluate 83 MB of source on the device. Parse-only cost on my laptop, measured with node --check (no execution, no React, no rendering):

real 2.80
real 1.92
real 1.47
Enter fullscreen mode Exit fullscreen mode

Call it 1.5–2.8 seconds, before any of my code runs, on an M-series Mac. A mid-range Android phone is not faster at this than a laptop. That is the entire cold-start budget spent on reading paragraphs that the user will not see for another four taps.

Why this is specifically worse inside a packaged app

On the web, this is a bad first-load story. In a Capacitor build it is three separate problems:

Install size. With images and fonts included, what ships on disk is roughly twice the size of that script alone. All of it is copied into the binary. Store listing pages show that number to users before they have seen a single screen.

No delivery tricks. No CDN, no HTTP caching between releases, no range requests, no "load the rest later" over the network. The WebView reads the file from disk. Every improvement has to happen at build time.

Updates are whole. A fix to one typo in one paragraph can produce a new binary, because the paragraph is compiled into the bundle. There is no such thing as a small content patch.

On the web you would split the route and ship a spinner. In a packaged app the same weight shows up as install friction and cold start, and no RUM dashboard will page you about it.

The split

I did not shrink the content. Truncating a content product to hit a byte budget is solving the wrong problem. I moved the content out of the parse path.

The key detail is that the record keys are already hierarchical — group-male-01, group-female-03 — so the first segment is a natural partition. Instead of one module that contains everything, the build now emits one JSON file per group and a small manifest:

public/content/manifest.json      # { "<group>": <count>, ... }
public/content/<group>.json       # records for that group
...
Enter fullscreen mode Exit fullscreen mode

The loader is about thirty lines and does not need a framework:

type Manifest = Record<string, number>;
let manifest: Manifest | null = null;

export async function loadGroup(id: string): Promise<Record<string, unknown>> {
  manifest ??= await fetch('/content/manifest.json').then(r => r.json());
  if (!(id in manifest)) throw new Error(`unknown group: ${id}`);

  const res = await fetch(`/content/${id}.json`);
  if (!res.ok) throw new Error(`content ${id}: ${res.status}`);
  // JSON.parse beats JS parse per byte, and it cannot execute anything
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Three things made this work in practice:

  1. fetch on a local origin works in the WebView. A bundled Capacitor app still serves capacitor://localhost (or http://localhost on Android) through its internal scheme handler. Relative fetch to a file inside dist/ behaves like a normal static request.
  2. JSON.parse is cheaper than executing a JS object literal. Both are linear in bytes, but the JSON parser does not build a scope, does not evaluate expressions, and can bail out on malformed input instead of running it. For pure data, JSON is the right container.
  3. The variant split happens after load, not before. All variants come down with the record. Splitting to one variant per request would have cut bytes on paper and tripled the request count for a user who swipes through the gallery — the wrong trade on a device where every request costs a tick.

The entry chunk now carries routing, state, and UI. Content arrives per group, and the first screen requests one file for the one group it is showing.

The budget check I keep in CI

The rule I settled on is boring and has caught two regressions since:

// scripts/budget.mjs — runs after build, exits non-zero on breach
import { readdirSync, statSync, readFileSync } from 'node:fs';

const dir = 'dist/assets';
const CHUNK_LIMIT = 2 * 1024 * 1024;   // 2 MB per chunk, raw
const LINE_LIMIT  = 100 * 1024;        // 100 KB in one line = inlined data

let failed = false;
for (const file of readdirSync(dir).filter(f => f.endsWith('.js'))) {
  const path = `${dir}/${file}`;
  const size = statSync(path).size;
  if (size > CHUNK_LIMIT) {
    console.error(`chunk over budget: ${file} = ${size} bytes`);
    failed = true;
  }
  const longest = readFileSync(path, 'utf8').split('\n')
    .reduce((max, line) => Math.max(max, line.length), 0);
  if (longest > LINE_LIMIT) {
    console.error(`inlined data suspected: ${file} longest line = ${longest}`);
    failed = true;
  }
}
process.exit(failed ? 1 : 0);
Enter fullscreen mode Exit fullscreen mode

The line-length check is a heuristic, and I know it. It is also the only check in the file that ever predicted a real problem before a human noticed.

What I would tell someone packaging a content app

  • Measure the artifact, not the dependency tree. The analyzer answers "which packages are heavy". Data you authored is invisible to it, and data you authored is usually the weight.
  • Keep content out of the parse path from day one. Not "we will split it later" — later means after the prose has grown past 50 MB and every key is already compiled in.
  • Treat a long single line as a bug signal in review. It costs one awk and it is unambiguous.
  • Compression is a transfer story, not a runtime story. 2.62× smaller on the wire; the same bytes to parse.

None of this is exotic. It is the ordinary consequence of a content product whose content was stored where code goes.


I build NameSoul, an app that gives people a Chinese name along with the historical story behind it — a content-heavy product in ten languages, and, for a while, one very large JavaScript file. If you are shipping a text-heavy app through Capacitor, the numbers above are yours to reuse: namesoul.net.

Top comments (0)