Skip to content

Split the frontend bundle: 1.44 MB entry chunk down to 246 kB - #6

Merged
krlex merged 2 commits into
developfrom
perf/split-the-frontend-bundle
Aug 19, 2026
Merged

Split the frontend bundle: 1.44 MB entry chunk down to 246 kB#6
krlex merged 2 commits into
developfrom
perf/split-the-frontend-bundle

Conversation

@krlex

@krlex krlex commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Closes L3 from the 2026-08-19 Codex review.

First commit: stop committing the Vite build output

build/ was never in .gitignoredist/ is there, but vite.config.ts sets
outDir: build/forail. Nothing had ever committed it until a git add -A after
a local npm run build swept it in, and merging #5 put ten generated assets plus
the tsc incremental state into develop. That is what this PR was conflicting
on: two branches that each ran a build disagreeing about files nobody edited.

They are noise there regardless — the Dockerfile is multi-stage and runs
npm run build in the builder stage, so the committed copy is never the one that
ships. Removed from the index and ignored, along with *.tsbuildinfo and the
vite.config.d.ts that tsc -b emits. After a full local build, git status is
clean.

The entry chunk

All 91 pages were static imports in App.tsx, so they landed in one chunk that
every visitor downloaded and parsed before the first screen appeared — while a
session typically touches a handful of them.

entry chunk   1.44 MB  ->  246 kB   (gzip 75 kB)

Loaded on demand now, behind a Suspense boundary inside the layout so the
chrome stays put while a page arrives. Login and ForcePasswordChange stay
eager — they are the first paint for an unauthenticated or first-login user, so
deferring them would only add a spinner ahead of the thing being waited for.

What the split uncovered

Splitting the routes exposed a second problem the single bundle had been hiding:
ServicePortal came out at 753 kB on its own, from
import * as Icons from 'lucide-react' — the entire icon library, because a
catalog item names its icon and any of them can be asked for.

Resolved through lucide's dynamic import map instead, one icon at a time,
memoised on the name (calling lazy() during render returns a new component type
each pass, and React would unmount and remount the icon every time).

ServicePortal  753 kB  ->  205 kB   (gzip 50 kB)

What remains above 200 kB is vendor code that already loads only with the pages
using it: recharts, xterm, xyflow.

Keeping it

npm run check-bundle fails on an entry chunk over 400 kB or any other chunk
over 500 kB, and runs in CI after the build. The budgets are deliberately
generous against today's numbers — this is a ratchet against regression, not a
target to squeeze against.

The route test also asserts which pages may be eager, so re-adding a static
page import fails there with an explanation rather than only showing up as a
larger number.

Verified

krlex added 2 commits August 19, 2026 11:05
`build/` was never in .gitignore — `dist/` is there, but vite.config.ts sets
`outDir: build/forail`. Nothing had ever committed it until a `git add -A` after
a local `npm run build` swept it in, and merging that PR put ten generated
assets plus the tsc incremental state into develop.

They are pure noise there. The Dockerfile is multi-stage and runs `npm run build`
in the builder stage, so the committed copy is never the one that ships. What it
does produce is conflicts: any two branches that each ran a build disagree on
files nobody edited, which is exactly what happened between #5 and #6.

Removed from the index and ignored, along with `*.tsbuildinfo` and the
`vite.config.d.ts` that `tsc -b` emits.
…erly

All 91 pages were static imports in App.tsx, so they landed in one entry chunk
that every visitor downloaded and parsed before the first screen appeared --
while a session typically touches a handful of them.

They are loaded on demand now, behind a Suspense boundary inside the layout, so
the chrome stays put while a page arrives. Login and ForcePasswordChange stay
eager: they are the first paint for an unauthenticated or first-login user, and
deferring them would only add a spinner ahead of the thing being waited for.

    entry chunk   1.44 MB -> 246 kB   (gzip 75 kB)

Splitting the routes exposed a second problem it had been hiding: ServicePortal
came out at 753 kB on its own, from `import * as Icons from 'lucide-react'` --
the whole icon library, because a catalog item names its icon and any of them
can be asked for. Resolved through lucide's dynamic import map instead, one icon
at a time, memoised on the name so lazy() is not called fresh on every render.

    ServicePortal  753 kB -> 205 kB   (gzip 50 kB)

What remains above 200 kB is vendor code that already loads only with the pages
that use it: recharts, xterm, xyflow.

A budget guards it. `npm run check-bundle` fails on an entry chunk over 400 kB
or any other chunk over 500 kB, and runs in CI after the build -- otherwise the
next eager import silently puts it back. The route test now also asserts which
pages may be eager, so the same mistake fails there with an explanation rather
than only as a size number.
@krlex
krlex force-pushed the perf/split-the-frontend-bundle branch 2 times, most recently from a7911fe to fca869e Compare August 19, 2026 21:29
@krlex
krlex merged commit 9f2c6de into develop Aug 19, 2026
2 checks passed
@krlex
krlex deleted the perf/split-the-frontend-bundle branch August 19, 2026 21:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant