Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

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 Seventy-nine megabytes of JavaScript. One file.

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

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.

The app has 282 portrait records. Each record has two text sections — a long one shown under the portrait, a short one shown in a separate panel — and each section has five variants (A through E) so a user does not see the same paragraph twice. Every string exists in ten languages, plus the Chinese source text kept alongside.

282 × 2 × 5 = 2,820 text blocks. Times ten languages is 28,200 strings. That is a content product, and the content is the product.

The data lived in a module like this:

// past_life_stories.ts
const db: PastLifeStoryDB = {};

db["xia-male-01"] = {
  portrait: { A: { en: "...", cn: "...", es: "...", /* 8 more */ }, B: { /* ... */ }, /* C, D, E */ },
  moment:   { A: { /* ... */ }, /* B, C, D, E */ },
  keywords: { /* ... */ },
};

db["xia-male-02"] = { /* ... */ };
// ... 280 more assignments
export default db;

And it was imported the normal way:

import db from './data/past_life_stories';

That is the whole story. A 53 MB TypeScript source file 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 the content for 282 portraits" 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

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

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. dist/ is 177 MB: 98 MB of static assets (767 WebP images, 597 SVGs, a 5.6 MB calligraphy TTF) plus the 79 MB script. 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 — xia-male-01, shang-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 dynasty and a small manifest:

public/content/manifest.json      # { "xia": 26, "shang": 20, ... }
public/content/xia.json           # 26 records
public/content/shang.json         # 20 records
...

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 loadDynasty(id: string): Promise<Record<string, unknown>> {
  manifest ??= await fetch('/content/manifest.json').then(r => r.json());
  if (!(id in manifest)) throw new Error(`unknown dynasty: ${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();
}

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 five 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 dynasty, and the first screen requests one file for the one dynasty 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);

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 to 53 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 — 282 portraits, 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.