Performance Engineering
The critical rendering path, code splitting and bundle budgets, what LCP, INP, and CLS actually measure, and how the browser, CDN, and service worker caches stack.
Advertisement
Why It Matters
Performance is not a phase at the end of a project. Almost everything that determines whether a page is fast was decided by architecture: how it renders, where data is fetched, how the bundle is split, and what is allowed to block the main thread. By the time someone is asked to "make it faster," the expensive decisions have already been made.
This article covers the mechanics you need to reason about those decisions before they harden - what actually blocks a paint, what a bundle budget is for, what the Core Web Vitals measure, and how the caching layers stack.
The Critical Rendering Path
The sequence from bytes on the wire to pixels on screen. Knowing what blocks each step is what makes performance debugging systematic rather than superstitious.
1. DOM construction. The parser reads HTML and builds the DOM tree incrementally. A synchronous <script> tag blocks this - the parser stops, downloads, executes, and only then resumes, because the script might modify the document. defer and async remove that block.
2. CSSOM construction. Stylesheets are parsed into the CSSOM. CSS is render-blocking: the browser will not paint until it has the complete CSSOM, because painting with partial styles means a flash of unstyled content followed by a repaint. A single large stylesheet in the head is frequently the real cause of a slow first paint.
3. Render tree. DOM and CSSOM combine into the tree of what will actually be painted - visible nodes with their computed styles. Nodes with display: none are excluded here.
4. Layout. Geometry is computed: position and size for every node. This is where a missing image dimension becomes a problem, because the browser reserves no space and everything below shifts when the image arrives.
5. Paint and composite. Pixels are filled and layers are composited, ideally on the GPU.
Diagram100%flowchart TB HTML["HTML bytes arrive"] --> DOM["DOM construction<br/>blocked by sync scripts"] CSS["CSS bytes arrive"] --> CSSOM["CSSOM construction<br/>RENDER-BLOCKING"] DOM --> RT["Render tree"] CSSOM --> RT RT --> Layout["Layout - geometry"] Layout --> Paint["Paint + composite"] Paint --> FP(["First Paint"]) Paint --> LCP(["LCP measured here<br/>largest element painted"]) Layout --> CLS(["CLS accumulates here<br/>every unexpected shift"]) JS["JavaScript executes<br/>hydration, handlers"] --> MT["Main thread busy"] MT --> INP(["INP measured here<br/>input to next paint"]) style CSSOM fill:#3f1e1e,stroke:#ef4444 style LCP fill:#1e3a5f,stroke:#3b82f6 style CLS fill:#1e3a5f,stroke:#3b82f6 style INP fill:#1e3a5f,stroke:#3b82f6visualized by
Code Splitting
The bundle is one file only because a bundler was told to make it one. Splitting it is about not shipping code for screens the user has not visited.
Route-based splitting is the default and the highest-leverage version. Each route becomes a chunk, loaded on navigation. A user who lands on the marketing page never downloads the settings screen. This should be automatic in any modern framework, and if it is not, it is the first thing to fix.
Component-based splitting goes finer: a heavy component - a rich text editor, a charting library, a video player, a date picker with locale data - is loaded on demand rather than with its route.
const Editor = lazy(() => import('./Editor'));
The bundler sees the dynamic import(), cuts everything reachable from it into a separate chunk, and emits a runtime that fetches that chunk on first evaluation.
Two failure modes to design around:
Over-splitting. Fifty tiny chunks means fifty requests, and per-request overhead plus lost compression efficiency can be worse than one moderate bundle. Split at meaningful boundaries, not everywhere possible.
Splitting on the critical path. Lazy-loading a component the user sees immediately just moves its cost from the initial bundle to a slower serial request. Lazy-load what is below the fold or behind an interaction - not what is visible at load.
Bundle Budgeting and Tree Shaking
A budget is a byte ceiling enforced in CI. Without one, bundle size only moves in one direction, because every individual addition is small and nobody is accountable for the total.
The number should be derived from a device and network target, not chosen for roundness: pick the device and connection you are committing to support, decide the time-to-interactive you want on it, and work backwards. Then enforce it as a build failure. A warning gets ignored by the third week.
Tree shaking is how unused exports get dropped, and it depends on properties of your code rather than on the bundler being clever:
It requires ES modules. import/export bindings are static - determinable by reading the source without running it - so the bundler can build an exact used-exports graph. CommonJS require() is a runtime function call that can take a computed argument, so no static graph is possible.
It requires side-effect-free code. Even with a static graph, a bundler will not remove a module it believes does something at import time. A module that mutates a global, registers something, or patches a prototype on import must be kept. This is what "sideEffects": false in package.json declares, and a library that omits it will be included whole even when you import one function from it.
The practical consequence: an import of one utility from a library that ships CommonJS and does not declare sideEffects costs you the whole library. Checking that before adding a dependency is cheaper than removing it later.
Core Web Vitals, Precisely
Each metric measures a different failure, and each is moved by different architectural decisions.
LCP - Largest Contentful Paint
When the largest content element in the viewport finished rendering. A proxy for "when did the page look loaded."
It decomposes into four parts, which is what makes it debuggable: time to first byte, resource load delay (how long until the resource was discovered), resource load time, and element render delay.
What moves it:
- Rendering strategy. Server-rendered or static HTML paints far sooner than a client-rendered page that must download and execute JavaScript first.
- Resource discovery. An image referenced from CSS or injected by JavaScript is discovered late, after the CSS or JS has parsed. Putting it in the HTML, or preloading it, removes that delay.
- Render-blocking CSS. Nothing paints until the CSSOM is complete.
- Image format and sizing. Serving a 3000px hero to a 400px viewport is the most common single cause.
INP - Interaction to Next Paint
The latency from a user interaction to the next frame that reflects it, reported at roughly the worst interaction of the session. This is the metric that captures "I clicked and nothing happened."
It is almost entirely a main thread story:
- Hydration. A large tree hydrating blocks the thread precisely when early interactions arrive. This is why islands and partial hydration exist.
- Long tasks. Any task over 50ms delays input handling. Big synchronous work - parsing a large payload, sorting a long list, an expensive re-render - needs breaking up or moving to a worker.
- Handler cost. A click handler that triggers a re-render of a large subtree pays that cost inside the interaction window.
CLS - Cumulative Layout Shift
The sum of unexpected layout shifts. It measures whether content moves under the user's finger.
Its causes are unglamorous and almost entirely preventable:
- Images and video without dimensions. No reserved space, so everything below jumps when they load.
width/heightattributes or anaspect-ratiofix it. - Fonts. A web font swapping in with different metrics reflows every line of text.
font-display: swapavoids invisible text but causes the shift; the fix is to keepswapand match the fallback's metrics usingsize-adjustand the font override descriptors, then preload the font so the window is short. - Injected content. Banners, ads, and consent dialogs inserted above existing content. Reserve the space or render them in a layer that does not displace anything.
- Client-rendered content replacing a skeleton of a different size.
The Caching Stack
Three independent caches sit between your origin and the user, and the invalidation story has to work across all three - a mistake in any one of them can serve stale code indefinitely.
Diagram100%flowchart LR U["User navigates"] --> SW{"Service worker<br/>fetch handler"} SW -->|"cache hit"| SWC[("SW Cache<br/>app shell, offline")] SWC -.->|"instant, works offline"| U SW -->|"miss / passthrough"| HC{"HTTP cache<br/>browser disk"} HC -->|"fresh"| HCC[("Browser cache<br/>Cache-Control max-age")] HCC -.->|"no network"| U HC -->|"stale / miss"| CDN{"CDN edge"} CDN -->|"edge hit"| CDNC[("Edge cache<br/>s-maxage")] CDNC -.->|"one short hop"| U CDN -->|"edge miss"| Origin[("Origin server")] Origin -.-> U style SWC fill:#1e3a5f,stroke:#3b82f6 style HCC fill:#3f2d1e,stroke:#f59e0b style CDNC fill:#1e3f2d,stroke:#22c55evisualized by
Browser HTTP cache. Governed by Cache-Control. Per-user, per-device, and the only layer you cannot purge - once a response is cached with a long max-age, that user has it until it expires.
CDN edge cache. Shared across all users at an edge location. Controlled by s-maxage, and crucially it is purgeable, which is what makes long TTLs safe for content that changes.
Service worker cache. Fully programmatic - your code decides what to store and what to serve. It is the only layer that works offline, and the only one that can serve a response without any network involvement at all.
The invalidation strategy that makes this work is content hashing:
- Hashed static assets (
app.a3f9c2.js) getmax-age=31536000, immutable. Safe forever, because a changed file is a different URL. - HTML gets a short TTL or
no-cache, because it is what points at the hashed URLs. This is the file that must not be cached aggressively - get it wrong and users keep loading the old asset URLs no matter what the CDN does. - API responses get short
max-ageplus ETags for conditional revalidation. - The service worker itself must have its cache name versioned per deploy, or it will keep serving old assets regardless of what the other two layers do.
Tradeoffs
| Option | Pros | Cons | When to Use |
|---|---|---|---|
| Route-based splitting | Large win for little effort, natural boundaries, framework-automatic | Still ships everything a route needs, including rarely-used parts | Always - this is the baseline |
| Component-based splitting | Removes heavy dependencies from initial load | Over-splitting adds request overhead; wrong on the critical path | Editors, charts, players, anything below the fold or behind an interaction |
| Long-lived immutable caching | Repeat visits are near-instant, minimal origin traffic | Requires content hashing and disciplined HTML cache headers | All hashed static assets |
| Short TTL plus ETag | Always fresh, cheap revalidation with 304s | A round trip per request even on a hit | HTML documents and API responses |
| Service worker caching | Works offline, full programmatic control, instant repeat loads | Versioning bugs serve stale code, adds a real update lifecycle | Installable apps, unreliable networks, app shell architectures |
| Strict CI bundle budget | Prevents slow regression, forces explicit trade decisions | Can block delivery on a real feature, needs a documented waiver path | Any product with a performance commitment |
Where This Applies
The hydration cost that dominates INP is explained in Rendering Architecture - islands and resumability are the architectural answers to it. The waterfalls that inflate LCP are covered in Networking and Data Fetching. The service worker layer of the caching stack is examined in depth in Offline and PWA Architecture, and how these numbers are measured on real users rather than in a lab is the subject of Observability.
In the applied practice problems, this decides Image Gallery with Lazy Loading, where LCP and CLS are the entire problem, and Infinite Scroll, where DOM size and long tasks dominate INP - taken to its conclusion in Virtualized List. The frame budget is tightest of all in Drag and Drop and Video Player.
Advertisement
Why is CSS render-blocking but most JavaScript is not, and what follows from that?
The browser cannot paint without the CSSOM, because painting with incomplete styles would produce a flash of unstyled content followed by a repaint. So every stylesheet in the head blocks the first paint until it has downloaded and parsed. JavaScript is only parser-blocking when it is a plain synchronous script tag, because the browser must assume it might call document.write and change the document. Adding defer or async removes that block. What follows architecturally is that critical CSS should be inlined and everything else loaded non-blocking, and that a single large stylesheet in the head is often the actual cause of a slow first paint.
Why does tree shaking require ES modules?
Because ES module imports and exports are static - the bindings are determinable by reading the source without executing it, so a bundler can build an exact graph of which exports are used. CommonJS require is a runtime function call that can take a computed argument, so the bundler cannot know what will be imported without running the code. That is also why the sideEffects field in package.json matters - even with static analysis, a bundler will not drop a module it believes has import-time side effects, so a library that does not declare itself side-effect-free will be included whole even when you import one function from it.
Your LCP is 3.5 seconds. Walk through how you would diagnose it.
Break the number into its parts rather than guessing. First find what the LCP element actually is - usually a hero image or a heading - because the fix differs entirely. Then decompose the time - time to first byte, resource load delay, resource load time, and element render delay. A slow TTFB is a server or CDN problem. A long load delay usually means the resource was discovered late, because it was referenced from CSS or injected by JavaScript instead of being in the HTML, which a preload fixes. A long render delay usually means render-blocking CSS or the element being client-rendered. The point is that LCP is four different problems wearing one number.
How do font loading choices affect CLS, and what is the right strategy?
If a web font loads after first paint and has different metrics from the fallback, every line of text reflows when it swaps, which shifts everything below it and registers as layout shift. font-display block hides the text until the font arrives, avoiding the shift but producing invisible text. font-display swap shows the fallback immediately, which is better for perceived speed but causes the shift. The architectural fix is to keep swap and eliminate the shift by matching the fallback metrics to the web font using size-adjust and the font metric override descriptors, then preloading the font so the swap window is short.
A CDN caches a JavaScript bundle for a year. You deploy a fix. How do users get it?
They get it because the URL changes. The bundle filename contains a content hash, so a changed file is a different URL that was never cached, and the immutable long-lived cache is safe precisely because the name is derived from the content. The file that must not be cached that way is the HTML that references those bundles - it gets a short TTL or must-revalidate, so a new deploy is picked up on the next navigation and points at the new hashed URLs. If a service worker is also caching, its own version must change too, or it will keep serving the old assets regardless of what the CDN does.