PERFORMANCE OPTIMIZATION · 2026
Frontend Performance Work
A six-week audit and remediation of Vite build performance across both tenant-facing portals — phased from build-config quick wins through route-level code splitting to CI-enforced budgets that make regressions impossible to merge unnoticed.
−98.5%
ADMIN PORTAL BUNDLE (GZIP)
−99.7%
CANDIDATE PORTAL BUNDLE (GZIP)
2
PORTALS OPTIMIZED
CI
GZIP BUDGETS ENFORCED PER PR
MY CONTRIBUTION
Performance Audit
Led
Build Tooling (Vite / Rollup)
Led
CI Integration
Led
Asset Optimization
Led
BEFORE / AFTER
BEFORE
Both portals shipped unminified, unsplit production bundles — a single eagerly-loaded chunk pulling in rich text editors and proctoring SDKs regardless of route, shared vendor code quietly riding along inside the module-federation boundary's eagerly-preloaded chunks, and uncompressed PNG hero images with no preload hints.
AFTER
Minified, route-split bundles with manually-grouped chunks, a tree-shaken shared UI package, deferred editor/proctoring payloads, WebP hero assets, and CI-enforced gzip budgets gating every PR.
CONTENTS
01 BACKGROUND
02 APPROACH
Background
Both tenant-facing portals — the candidate exam portal and the admin portal — had accumulated build-performance debt. None of it was catastrophic on its own, but stacked together it meant slow login renders and unpredictable load times on exactly the kind of network conditions a candidate might be on walking into a proctored exam. I took ownership of the audit and the six-week remediation that followed. The audit surfaced four distinct issues: no production minification on either portal; no CSS code-splitting, so every route paid for every route's styles; protected page components statically imported at the route level, which pulled rich text editors and proctoring SDKs into the initial load whether or not a given candidate would ever touch them; and uncompressed PNG hero imagery on the login screen with no preload hints. A fifth, deeper issue only surfaced once the obvious fixes were in: shared vendor code kept ending up eagerly loaded regardless of which route needed it — the actual root cause turned out to be how the app's module-federation boundary interacts with the bundler's default chunking, covered below.
Approach
Rather than fixing issues as I found them, I ran it as four phases, each building guardrails the next phase could rely on. Phase 1 was build-configuration quick wins: turn on the minification and CSS code-splitting that should have been on already. Phase 2 was route-level code splitting — converting static page imports to lazy dynamic imports with Suspense boundaries, then discovering that splitting the routes wasn't enough on its own and working out why. Phase 3 was library and asset optimization: unlocking tree-shaking on the shared UI package, deferring the rich text editor and the WebRTC proctoring SDK so they only load when a candidate actually reaches an authoring or oral-exam surface, and converting the login hero image to WebP. Phase 4 was guardrails — a bundle visualizer for ongoing visibility, and CI-enforced size budgets so none of the first three phases' gains could quietly erode.

Explicit chunk ownership over Rollup's "first claim" default
DECISION
Wrote an explicit manualChunks function that gives every category of shared code — framework core, chart-rendering libraries, the shared UI kit, form/validation libraries, the app's auth and utility layer, each feature area — its own dedicated, named chunk, rather than leaving shared modules unclaimed and trusting Rollup to place them sensibly.
REASONING
The app uses module federation, which exposes a small set of modules as the app's public surface — those get preloaded on first paint by design. Rollup's default behavior assigns any unclaimed shared module to whichever chunk imports it first, and on a federated app that "first claim" is very often one of those eager expose entries. Leave a heavy shared dependency unclaimed and it only takes one page importing it before any other page does for that dependency to get swept into the eager, preloaded graph — regardless of how carefully each individual route was split. The fix wasn't really about splitting harder; it was about making sure every shared dependency has one deliberate, stable home, so "whichever chunk happens to import this first" can never again be the thing quietly deciding what a candidate downloads before first paint.
FIG. 04 — Project overview
CONTENTS
03 ROUTE-LEVEL CODE SPLITTING
04 LIBRARY & ASSET OPTIMIZATION
05 GUARDRAILS
06 OUTCOME
07 LESSONS LEARNED
Route-Level Code Splitting
The mechanical part was straightforward: swap static route imports for React.lazy() + dynamic import(), wrap each route in a Suspense boundary with a loading fallback. The part that took real effort was everything after — because on a module-federation app, splitting the routes doesn't automatically mean the shared code underneath them stays lazy. Federation exposes a handful of modules as the app's public surface, and the host app preloads those on first paint by design. Any shared dependency that isn't explicitly assigned a chunk gets folded by Rollup into whichever chunk imports it first — and because the federation expose entries and the app shell both import early, that's very often an eager one. I ended up writing out an explicit chunk map: framework core, chart-rendering libraries, the shared UI kit, form and validation libraries, the app's auth and utility layer, and every individual feature area, each with its own named, stable chunk. Route splitting only holds if every shared dependency it touches has somewhere deliberate to live — that's the decision covered in more depth in the sidebar.

Library & Asset Optimization
Two payloads dominated the eager bundle even after route splitting: the rich text editor (used only when authoring exam content) and the WebRTC proctoring SDK (used only inside an oral exam session). Neither was needed on first paint for the vast majority of candidates, so both got deferred behind dynamic imports tied to the surfaces that actually use them — not removed, deferred, since both are load-bearing features elsewhere in the product. The shared UI component package was marked side-effect-free in its package.json, with targeted exceptions for the handful of modules that do have real side effects (mostly CSS imports). That single annotation is what lets Rollup's tree-shaking actually drop unused exports instead of conservatively keeping everything because it can't prove nothing depends on it. On the asset side, the login hero image moved from an uncompressed PNG to WebP with a PNG fallback, plus a preload hint so the browser starts fetching it before it's discovered mid-render.

Guardrails
Fixes that don't prevent regression are temporary. I wired in a bundle analyzer in visualizer mode so anyone on the team can see what's actually shipping and where new weight lands, and added a CI script that asserts gzip size budgets at build time — gzip, not raw byte counts, since gzip is what actually crosses the wire. The budgets check three things, not one: the entry script, each named route chunk, and the total "eager set" — the entry plus everything the browser preloads before first paint. That third number is the one that actually matters for the class of bug this whole effort was chasing: a per-chunk budget can't catch a shared dependency getting swept into an eager chunk, because every individual chunk still measures "within budget" on its own. The eager-set total is what a candidate genuinely downloads before they see anything, and it's the number a PR can't quietly inflate without CI catching it. I also captured before/after Lighthouse runs against a dev deployment to have a concrete before/after on Largest Contentful Paint, not just bundle byte counts — bundle size is a proxy for load performance, not the thing itself.
Outcome
Gzipped initial-route JS on the admin portal dropped from 256 KB to 3.8 KB (−98.5%); the candidate portal dropped from 1.86 MB to roughly 100 KB (−99.7%). Both numbers hold under CI budget enforcement, so neither can silently regress. Largest Contentful Paint on the login route improved measurably from the preload hint and WebP conversion alone, independent of the bundle work. Heavy feature code — the editor, the proctoring SDK — no longer sits in the critical path for candidates who never touch those surfaces.
Lessons Learned
Turning on code splitting felt like the finish line; it was actually the point where the real work started. I'd expected route-level splitting to be mostly mechanical — swap the imports, add Suspense boundaries, done. Instead it surfaced a deeper question about where shared code lives, and answering that properly took longer than the splitting itself. The chunk map wasn't a one-time decision either. Shipping the first version immediately surfaced more cases of the same underlying problem in a different shape: two unrelated lazy features that happened to share a component, which created a circular dependency between their chunks once each claimed the shared piece for itself. The fix was always the same move — give the shared piece its own dedicated chunk instead of letting whichever feature imported it first claim it. That the identical fix kept working on each new case was itself a useful signal that the underlying rule was right, even though I couldn't have listed every edge case up front.