Frontend Performance: Lessons From a 325-Byte Bundle Failure
A practical client optimization review covering boot-graph budgets, lazy boundaries, stable vendor chunks, batched initialization reads, and event-driven refresh.
A recent client release stopped during the build.
The authenticated App boot graph had a raw limit of 1,600 KiB. The candidate contained 1,638,725 bytes—325 bytes over the limit.
Raising the ceiling would have taken less time than investigating it. That is also how clients slowly become heavy: every feature adds “just a little,” until nobody can explain why all of it must load at startup.
We kept the budget. The revised implementation preserved the same loading feedback, duplicate-submit protection, and accessibility behavior. The build then reported 1,600.0 KiB raw and 444.8 KiB gzip.
The small failure clarified the order I now use for frontend performance work: control what every user must pay for at startup, then remove repeated work while the page stays open.
Measure the boot graph, not one entry file
Bundle-size reviews often start and end with app.js. The browser does not stop there. It follows static imports into React, state libraries, realtime clients, and shared modules.
Measuring only the entry file is like weighing an empty suitcase and ignoring what goes inside it.
Our build gate walks the static graph from the App entry and the React DOM renderer. It measures every file that cold startup must reach, both raw and gzip-compressed. Three budgets cover different costs:
| Scope | Limit | What it guards |
|---|---|---|
| App entry | 160 KiB | Startup coordination code growing without bound |
| Complete boot graph, raw | 1,600 KiB | Code the browser still has to parse and execute after transfer |
| Complete boot graph, gzip | 480 KiB | Initial network transfer |
These numbers are not interchangeable. A good gzip result does not remove the cost of parsing and executing the expanded JavaScript. A cold load is also different from a repeat visit that can reuse browser and Service Worker caches.
A bundle budget does not prove that a page is fast. It catches a more specific regression: an innocent-looking import pulling a chart, editor, or Markdown stack into startup.
Split by when the code is needed
The answer is not to turn every module into a dynamic import. Excessive splitting adds requests, loading states, and failure boundaries. It can replace one large bundle with confetti.
We use a simpler rule: code for a feature the user is not using now should not enter the App boot graph.
| At App startup | When the user enters the feature |
|---|---|
| Authentication, localization, navigation, loading, and error shell | Dashboard and other business routes |
| Minimum shared runtime | App Agent on first open |
| Minimum realtime client | Global Search on first use |
| Charts, Markdown, code, and document editors |
Every business route is a separate dynamic import. The App Agent and Global Search load on first activation, then stay mounted so reopening them does not repeat initialization. Large chart, Markdown, and editor dependencies remain outside the boot graph.
Lazy loading does not need to wait until after the click. Pointer hover or keyboard focus on an eligible navigation link can prefetch its route module. This fetches static code only; it does not read user data or trigger a business operation.
The goal is not to push initial bytes toward zero. It is to deliver the code needed now, while preparing likely next-step code without making the user pay for every optional feature.
Keep stable dependencies cacheable across releases
Splitting code helps only when cached chunks remain reusable.
If every business edit gives React, Zustand, Immer, Zod, or Socket.IO a new URL, users download unchanged dependencies again. The dependency did not change; its cache identity did.
We compile common dependencies into independent, versioned vendor chunks. Their URLs include both the installed version and a content hash. An application-only edit must leave those vendor URLs and bytes unchanged. An actual dependency upgrade produces a new immutable address.
The boundary also ensures that lazy features and the shell resolve the same React and state-library instances. A split build that loads two copies of React is not much of an optimization—and it can create Hook and context failures as a bonus.
Good chunking therefore does two jobs: it reduces cold startup and lets unchanged code keep hitting cache after a business release.
A new visual state does not always need a new component tree
The direct cause of the 325-byte overage was a pending indicator for the desktop login callback. It had real requirements: block duplicate decisions, keep button geometry stable, announce progress to assistive technology, and remain clear when motion is disabled.
Those requirements did not require another set of wrappers and animation nodes. The corrected version reused the button’s existing leading icon, applied a CSS pending pulse, kept the trailing affordance in place, and tightened the status subscription.
This is not an argument that CSS is always better than JavaScript. The useful question is whether an interaction introduces new business state. If it only gives existing state a visual treatment, first see whether the current DOM and CSS can express it. Do not build a second state tree for an effect.
Reduce requests after the code has loaded
Client performance work does not end when JavaScript finishes loading. Request count and refresh behavior continue to affect responsiveness, battery use, and server load.
The first target is initialization. Dashboard and Market surfaces need the current user, settings, notices, favorites, groups, and page data. Sending each read separately repeats connection and admission work.
We retain an independent result and error for each business read, while allowing one bounded initialization snapshot to carry 1–12 approved reads with one user admission. Concurrent lightweight reads can join a short batch. Large candle streams stay separate, so request batching does not become one unbounded response.
Server-rendered first-page data is consumed once during hydration when it matches the restored resource. The browser reads again only when the restored selection differs. This avoids the familiar pattern where the server just fetched a page and the client immediately fetches the same page again.
The second target is polling. Pending orders once refreshed every five seconds, and several other surfaces used timed refresh or switched to HTTP polling after a disconnect. Most of those requests returned “nothing changed.”
The current browser rule is narrower:
- read on initial mount;
- read after an explicit user operation;
- read after a matching resource event;
- reconcile once after reconnect.
Bursts for the same resource are coalesced. Unmounted or inactive surfaces do not start work, and late responses cannot replace newer state.
An event is only a refresh hint, not authoritative data. Reconnection still reads current state. This distinction removes recurring traffic without leaving the UI permanently stale when one realtime message is lost.
Put the budget in the normal build
Frontend weight tends to return unless the boundary is executable.
Our build and regression checks now verify that:
- the complete static boot graph stays within raw and gzip budgets;
- Dashboard, App Agent, charts, Markdown, and editors do not enter that graph;
- application-only changes do not rewrite stable vendor assets or load duplicate framework instances;
- advancing browser time without an event does not trigger business polling;
- an over-budget build fails instead of quietly raising the limit at release time.
This does not automatically solve image decoding, long-list rendering, complex tables, or a slow data source. Those need their own measurements. The gate solves a more basic problem: a new feature should not quietly charge its startup cost to every user.
The 325 bytes were ultimately removed without a magic compression flag. We deleted unnecessary representation and used the existing UI more completely.
Client slimming rarely has one dramatic move. The useful habit is having a gate that asks the same question whenever “just a little more” appears: does every user really need to download this now?