The document detail page is server-rendered, its content arrives quickly, and its headline metric looks respectable. It also feels slow, because the revision switcher — the control the page exists for — does not respond for a while after everything appears finished.

That gap between “looks fast” and “is usable” is what this article is about. The method is unexciting, and following it is what makes the difference: identify which kind of waiting you have, change the thing that causes that kind, and measure again at the same boundary.

The examples are from the fictional document application, and the illustrations are drawn rather than captured. Where effects are described, they are described as kinds of work avoided, not as measurements.

Table of Contents

A Page That Looks Fast and Feels Slow

Three things can make the same page unsatisfying, and they have nothing to do with each other:

  • the server took a long time to produce the HTML;
  • the HTML arrived quickly but the largest visible element appeared late, because a font, an image or a stylesheet was discovered late;
  • everything was visible quickly and the page ignored input, because the main thread was busy executing and hydrating JavaScript.

Only the third one describes the revision switcher. Optimizing the first — caching harder, tuning the gateway — would be work with no effect on the complaint. That is the most common way performance effort gets wasted, and the reason the rest of this article starts with measurement rather than with techniques.

Naming the Kinds of Waiting

Each signal answers one question and is silent about the others. Confusing them is what produces the “we improved the score and users noticed nothing” outcome.

Signal What it helps explain What it does not establish
HTTP response duration Server and transport work at a chosen boundary Anything about rendering or interactivity
First Contentful Paint When something appeared Whether it was the content or the controls
Largest Contentful Paint When the largest qualifying element rendered Whether the page responds to input
Cumulative Layout Shift Unexpected movement of content Load speed
Total Blocking Time Blocking portions of long tasks in a lab window All of the delay between visible and usable
Interaction to Next Paint Responsiveness of real interactions in the field Which code caused a slow interaction

The switcher problem lives in the last two rows, and it is invisible in the second and third — which is exactly why a page can pass an audit and annoy its users.

Measuring Before Deciding

Two habits make the numbers usable.

Record the conditions with the result. Device profile, network throttling, cache state, consent state, and the version of the audit tool all change the outcome. A result without them cannot be compared to anything, including a later run of itself.

Repeat, and compare distributions rather than single runs. Browser measurements are noisy enough that one run can move a headline number by a margin larger than the change you are evaluating. If a change cannot be seen across repeated runs, it has not been demonstrated.

For anything published, describe effects as ranges or as the class of work avoided — “one fewer connection setup on the critical path,” “several hundred kilobytes no longer parsed during startup” — rather than as precise figures from a particular deployment.

Server Work: Not Doing It Twice

If the measurement points at response duration, the question is what the server is doing that it did before. The series has already built most of the answer: the in-process gateway from Part 2 removes a loopback HTTP hop but not the remote calls; the operation and result-set caches from Parts 3 and 9 remove repeated fetching when — and only when — their cache identity is right.

flowchart LR REQ["Request + context"] --> P{"Page result set cached<br/>for this identity?"} P -->|Yes| R["Render"] P -->|No| O{"Operation result cached?"} O -->|Yes| R O -->|No| U["Fetch from subgraphs"] U --> R

Two things are worth keeping in view while tuning this. The three cache levels — page result set, operation result, rendered HTML — have different identities, and the deeper you cache, the more inputs the key must include; rendered HTML in particular varies by experiment assignment and by every Part 10 condition. And the cold path still exists: every measurement of a warm cache is a measurement of the good case, so measure both, because the cold path is what a deployment or an invalidation produces and what a traffic spike will find.

Main-Thread Work: Scheduling Hydration

If the measurement points at blocking time or interaction latency, the cost is JavaScript executing during startup, and hydration is usually the largest single contributor.

flowchart LR HTML["Server-rendered HTML"] --> C["Hydrate essential controls promptly"] HTML --> D["Defer components that can wait"] D --> T["Trigger: visibility, idle, or interaction"] T --> H["Load and hydrate"] C --> V["Verify keyboard, pointer and touch behaviour"] H --> V

Component-level lazy hydration, where the framework supports it, is the controllable version of this: a specific component waits for a specific trigger. Deferring the whole entry script is a much more invasive change that interacts with navigation, preload hints, content security policy and build output, and it is easy to do in a way that only moves work outside an audit’s measurement window.

Keep that distinction in view, because deferring work and removing work are not the same thing. Deferred work still runs; it runs later, and the visitor may well be waiting for it by then. A visitor who clicks the revision switcher before it hydrates still needs the click to do something. Part 14 is entirely about choosing those boundaries, so the rule here is just: do not defer the control the page exists for.

Two mechanisms are commonly misread in this area.

A prefetch hint is a hint. <link rel="prefetch"> tells the browser that a resource may be useful soon; the browser decides whether to fetch it, and may skip it entirely under memory pressure, on a metered connection, or because more urgent work is queued. Code that assumes a prefetched chunk is already local still has to work when it is not.

The other is the belief that awaiting a promise gives the browser room to breathe. It does not, and the reason is how the event loop is structured. The browser runs one task at a time — a script, an event handler, a timer callback — and only between tasks can it style, lay out and paint. Promise continuations are microtasks, and the microtask queue is drained to exhaustion at the end of the current task, before the browser regains control. So this hydrates everything in one uninterrupted block, despite looking like it spreads the work out:

for (const component of components) {
  await Promise.resolve()   // microtask: no paint happens here
  hydrate(component)
}

To actually yield, the work has to end the current task and continue in a later one — setTimeout, requestIdleCallback, requestAnimationFrame, or scheduler.yield() and scheduler.postTask() where available. That is a real difference in what the visitor experiences: the same total work, in slices the browser can interleave with painting and input handling, rather than one long block during which the page ignores clicks.

Resource Discovery: Images

If the largest element is an image from a third-party origin, part of its cost is connection setup: DNS, TCP and TLS to a host the browser has not talked to yet, on the critical path.

Serving the image through your own origin removes that, because the connection already exists and a configured edge cache can serve the bytes:

sequenceDiagram participant B as Browser participant E as Edge cache participant P as Reverse proxy participant N as Image route participant U as Upstream image service B->>E: Request same-origin image alt Edge cache hit E-->>B: Cached image else Edge cache miss E->>P: Forward P->>N: Forward image path N->>U: Fetch allowlisted upstream resource U-->>N: Image bytes N-->>P: Response P-->>E: Response and cache policy E-->>B: Image response end

Note what the diagram does not contain: a local cache in the reverse proxy. Forwarding requests and setting cache headers is not the same as holding responses, so whether anything is actually cached depends on the edge configuration and the response’s own cache semantics. Check that rather than assuming it.

Two obligations come with such a route. It must validate the path and parameters and restrict upstream destinations to an allowlist, or you have built an open proxy that will be found and used. And it must preserve the content type and the upstream’s cache validators — the ETag and Last-Modified headers a browser sends back as “I already have this version, has it changed?”, which is what turns a repeat request into a cheap 304 Not Modified instead of a full download. Drop them and every revisit re-downloads the image.

The related decision is how long to allow caching. A long max-age with immutable tells browsers and edge caches never to revalidate, which is ideal for a URL whose content can never change — a hashed filename — and a trap for a stable URL whose content can, because there is then no way to correct a wrong image short of waiting out the lifetime everywhere. Long-lived immutable caching is safe exactly when a content change produces a new URL.

The benefit is workload-dependent — a saved connection matters most on a constrained mobile connection and least on a warm desktop cache, while a miss adds server-side forwarding work. When proxying adds more complexity than it removes, a preconnect hint is the simpler intervention: it overlaps connection setup with other work without putting your server in the image path.

Fonts and Layout Stability

Web fonts trade three things against each other: how soon text is readable, whether it looks right, and whether it moves. A fallback font with similar metrics reduces the movement when the web font takes over, and font-display: optional avoids a late swap entirely at the cost of some visits never seeing the web font.

@font-face {
  font-family: 'Example Sans';
  src: url('/fonts/example-sans.woff2') format('woff2');
  font-display: optional;
}

Preload only what is needed above the fold, and measure the actual fallback metrics for your typefaces rather than copying adjustment percentages from an article about different ones. And keep the scope honest: fonts are one source of layout shift among several, alongside images without intrinsic dimensions, late-inserted banners, and consent dialogs.

Third-Party Scripts and Audit Parity

Analytics, tag managers and support widgets are the easiest performance wins available, because much of what they cost is optional. Load them only when the visit needs them and consent permits, and schedule them out of the startup path:

flowchart TB N{"Needed for this visit?"} -->|No| S["Do not load"] N -->|Yes| C{"Consent present?"} C -->|No| W["Wait for consent"] C -->|Yes| Q["Schedule outside essential startup work"] Q --> M["Measure the effect on real interactions"]

Idle scheduling helps and guarantees nothing: an idle callback can still run while a user is trying to interact, and a timeout is not a promise about main-thread availability.

One practice deserves naming as the anti-pattern it is. Detecting audit tools by user agent and disabling expensive features for them, then publishing the resulting score, measures a configuration no visitor experiences. If you deliberately run a reduced mode — no third parties until consent, for instance — say so alongside the number, and compare like with like.

Bundles: Import Boundaries Rather Than Chunk Counts

A CMS catch-all route can statically reference every section component that might appear, which is how an application ends up shipping an editor nobody on this page will open. The important correction first: splitting chunks does not make statically imported code lazy. Manual chunk configuration changes grouping and cache reuse; what decides whether code is needed during startup is the import graph.

Look at what went unused. Record a coverage profile over a representative journey, not just the first screen — a module unused at first paint may be needed by the first interaction, and deferring that one makes the page worse.

Illustrative coverage panel showing used and unused portions of fictional modules, without customer data.

Illustration, not a captured measurement. Bar lengths are qualitative.

Then look at why it was included. The build analyzer shows which import pulled a large dependency into the initial bundle, which is usually more surprising than the size itself.

Illustrative bundle map grouping a fictional application's core, content, and optional editor modules.

Illustration, not a production bundle report. Areas are not measured sizes.

Then move the boundary. A dependency needed only by an optional editor belongs behind a dynamic import:

const LazyEditor = defineAsyncComponent(() => import('@/components/ExampleEditor.vue'))

Illustrative before-and-after import graph moving an optional editor behind a dynamic import.

Illustration of an import boundary, not a measured byte reduction.

Four checks are worth making routinely.

Use type-only imports (import type { … }) where you need a type and not a value, so the bundler removes the statement entirely rather than keeping the module in the graph for a type annotation.

Verify what barrel files contribute to the output. A barrel is an index.ts that re-exports a folder’s contents so consumers can write one import; the cost is that importing one symbol from it references the whole module, and the bundler can only drop the rest if tree-shaking — the elimination of exports nothing reaches — can prove that dropping them changes nothing.

That proof is easier to defeat than it looks, and the thing that defeats it is a side effect at import time: work a module performs merely by being evaluated, rather than when something calls into it. A module whose body only declares functions, classes and constants has none. A module that registers a global listener, patches a prototype, writes to window, installs a polyfill, reads configuration at load, or imports a stylesheet does have one, because evaluating it changes something outside itself.

// analytics.ts
window.addEventListener('error', reportError)   // side effect: runs on import

export function trackRevisionSwitch() { /* ... */ }

If nothing in the application calls trackRevisionSwitch, that function is unreachable and could be removed — but the listener registration cannot be, because dropping the module would change how the page behaves. A bundler cannot distinguish the two cases from the outside, so its safe default is to keep the module and evaluate it.

Declaring a package side-effect-free is how its author waives that caution. The sideEffects field in package.json states that evaluating the package’s modules does nothing observable, so a bundler may discard unused ones outright instead of merely stripping their unused exports:

{ "name": "example-ui", "sideEffects": false }

It can also name the files that do have effects, "sideEffects": ["**/*.css"] being the usual case, since a stylesheet import exists precisely for its effect. A package that omits the field says nothing, and a bundler reads that silence as “assume every module might matter” — so each one reachable through the barrel is kept and evaluated, however little of it you use. One such dependency behind one re-export is enough to pull the folder it sits in into the bundle. Read the build output rather than assuming.

Keep server-only modules out of browser import paths, and avoid a single oversized vendor chunk that makes unrelated features load together.

And after introducing any lazy boundary, exercise the route transitions and the first interaction, because a bundle report cannot tell you what that boundary cost. A report describes the build output: which modules ended up in which chunk, and how large each chunk is. Moving the editor behind a dynamic import improves that picture by construction — its code is no longer in the initial bundle, so the initial bundle is smaller, and the number is correct. What a report has no way of expressing is when the chunk arrives relative to when somebody needs it. The code did not stop existing; a request, a download, a parse and an evaluation were moved to the moment the button is pressed, and on a constrained connection that sequence is long enough to notice.

So the regression looks like this: a visitor presses Edit, nothing visible happens, and they press it again. Nothing was broken and no click was swallowed by a bug — the component that would have handled it had not loaded yet. The build output is right, the report is right, and the problem is in the timing between the two. Only using the application surfaces it, which is why this check is a behavioural one: trigger each lazy boundary immediately after paint, with network throttling on, and decide per component whether that wait is acceptable.

Rewriting the Rendered HTML

Nitro’s render:html hook can modify the final server output, which makes it a tempting place to rewrite resource URLs or reorder scripts. It is also a string-level dependency on framework output.

If you use it, preserve script attributes, content-security-policy nonces and escaping, and remember that a replacement written for one attribute form will miss srcset, encoded URLs, and anything generated later in the browser. Prefer structured hooks and URL helpers where they exist, and test against a production build — the development server emits different assets, so a passing dev test proves less than it appears to.

Verifying the Combined Result

Optimizations interact, and not always favourably. Deferring code improves a startup measurement and can move the cost into the first click. Aggressive caching improves the warm path and can leave the cold path worse, because more work is now bundled into the request that misses.

A small matrix is enough to catch most of that: cold and warm caches, an ordinary device profile and a lab profile, both consent states, the main routes, and the critical interactions on each. Server behaviour under concurrency is a different experiment with different tooling, which is Part 19.

The conclusion such a matrix supports is specific and modest: these changes reduced this kind of work under these conditions. That is a defensible claim, and it is the only kind available here. There is no guaranteed score, no universal latency saving, and no formula that combines several optimizations into a predicted total.

So the diagnostic to carry out of this article: before changing anything, write down which of the three kinds of waiting you are fixing and the measurement that shows it. If you cannot, the next change is a guess — and in performance work a guess is easy to mistake for a success, because after almost any change some number looks better than it did before.

What’s Next


Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.