The slowest-feeling page I can remember had a great Lighthouse score. [Confirm: the real example.] It loaded quickly and every lab number was green. But when you clicked "View details", nothing happened for a beat. Then a spinner. Then the content.
None of that showed up in the score, because a standard Lighthouse run measures a page loading in a lab. It doesn't click anything.1 The slow part was the moment someone asked the interface for something, which is exactly when they were paying the most attention.
Design tools miss that moment for a similar reason. They're built around frames, and frames are easy to love. But nobody uses a frame. People use the time between them: the half second after pressing a button, the moment a list reorders, the point where a request fails and the interface has to decide what to say.
That's where I think most of an interface's quality lives: in how long those moments take, how they feel, and what the interface shows and does while they last.
Build A · Waits for the content
Pale Hours
Sat, Nov 14
Build B · Opens the frame first
Pale Hours
Sat, Nov 14
Timed in this browser from your click. Only the network wait is simulated: 800 ms in both builds.
- Input delay
- Processing
- Presentation
- Network (simulated)
- Content render
- Pressed again while waiting
Build A · Waits for the content
Not run yet
Build B · Opens the frame first
Not run yet
Actual versus perceived
A single click passes through several kinds of time, and each one is felt differently.
| Layer | What's happening | How it feels |
|---|---|---|
| Network and server | The request travels and the response is built | Invisible, unless nothing else happens |
| Rendering and hydration | The browser draws the page and attaches behavior | A button that's visible but doesn't respond yet |
| Interaction latency | Input delay, event handlers running, the next frame painting | Whether the press registered |
| Visual feedback | The first sign the interface heard you | Whether it's working |
| Perceived responsiveness | All of the above, judged by a person | Whether it's fast |
Interaction latency is the layer that's easiest to overlook, and the browser now measures it directly. Interaction to Next Paint tracks how long it takes from a click, tap or key press to the next painted frame, across the whole visit.2 Google counts 200 milliseconds or less, at the 75th percentile, as good.3
The older response-time research still holds up. Jakob Nielsen described about a tenth of a second as the limit for something to feel instant, and about a second as the limit before someone's flow of thought breaks.4 So the same 800 milliseconds can feel completely different. If the modal frame appears within 100 milliseconds and the content fills in over the next 700, it feels responsive. If nothing changes for 800 milliseconds, it feels broken, and people click again.
Feedback while waiting
- Immediate feedback (a pressed state, the modal frame opening) confirms the input. It's nearly free and nearly always right.
- Loading indicators are honest but say nothing about how long. Fine for short waits.
- Skeletons preview the layout. They work when the content's shape is predictable, and cause layout shift when it isn't.5
All of these make waiting easier, and none of them make it shorter. A skeleton on a three-second request is a nicer-looking three-second request. I use feedback to cover the gap while fixing the cause, not instead of fixing it.
The states nobody drew
Take something small, like saving a show to a list. The designed version has two states: not saved, and saved. The real version has at least six.
- Pending. The request is in flight.
- Optimistic. The interface shows "saved" before the server confirms. Great for actions that rarely fail, but what happens when it doesn't?
- Failed. Does the person find out, and can they retry without starting over?
- Interrupted. They tap again while the first request is pending, or leave the page halfway through.
- Focused and disabled. Can a keyboard user tell where they are, and why something can't be pressed?
- Empty. The list they saved to has nothing in it yet. What does it say?
None of these are edge cases from the person's point of view. They're just what happened. Whether an interface feels reliable depends mostly on how it handles them.
Pale Hours
Sat, Nov 14
Saves to November shows.
Simulated network: each request takes 1.5 s, or fails after 0.3 s while offline. Going offline and leaving only happen inside the demo.
Idle · no request yet
Interruption
Continuity creates comprehension
When something changes on screen, a person is quietly asking two questions: what changed, and where did it go? Motion is one of the best tools for answering them. A card that expands into a detail view tells you where the detail came from and where it will go when you close it. And things that didn't change should stay still, because movement implies change.
That's the test I use to separate purposeful motion from decoration: does it answer one of those questions? A fade and slide on every route change usually doesn't. Decoration isn't always wrong (some brand moments earn it), but it shouldn't be the default, and it should never make someone wait.
Three ways to transition
To test this, I built one small interface three times and changed only the transition. [Confirm: the interface, e.g. a list of saved shows that opens into a detail panel.]
| Approach | Behavior | Where it works | Where it fails |
|---|---|---|---|
| Immediate | Content swaps instantly | Frequent, repeated actions, like arrowing through a list | Spatial context is lost, and it's easy to miss what changed |
| Decorative | A generic fade and slide | Rare moments where tone matters | Adds time without information, and looks awkward when interrupted |
| Continuous | The card becomes the panel while the list stays put behind it | Navigation where knowing where something went matters | More code, careful identity and focus handling, and tiring if slow |
[Confirm after building: what happened when each version was interrupted mid-transition, used with only a keyboard, and used with reduced motion on.]
The continuous version isn't automatically the best. For a list someone moves through dozens of times a minute, immediate is the more respectful choice.
SavedFive shows
Low Orbit Trio
Room Nine · Thu, Nov 12Tab, ↑ ↓, Enter and Esc work here at any time.
Open a saved show, or press Interrupt halfway.
// Immediate: the panel replaces the list in one frame.
function open() {
list.inert = true;
panel.style.visibility = 'visible';
panel.focus();
}
function close(card: HTMLElement) {
list.inert = false;
panel.style.visibility = 'hidden';
card.focus();
}Lines of code: Immediate 11 · Decorative 57 · Continuous 76
Engineering interaction quality
These states and transitions don't usually get skipped because of taste. They get skipped because they're hard to build correctly.
- Race conditions. Two requests are in flight and the older, slower one resolves last, overwriting the newer result. Canceling stale requests (an
AbortControllerper request) is part of the interaction design.6 - Element identity. A transition needs the same element before and after. If the framework unmounts and remounts it, the animation has nothing to hold onto.
- Focus. When a dialog closes or the focused row is deleted, focus has to go somewhere deliberate. Otherwise it falls back to the top of the page and keyboard users lose their place.
- Reduced motion.
prefers-reduced-motionshould swap movement for a quick crossfade or an instant change. It shouldn't remove the feedback.7 - Cancellation. An interrupted transition should reverse from where it is, not snap to the end and replay.
The modeling matters too. I'd rather describe the save button as one value than as a handful of booleans:
type SaveState =
| { status: "idle" }
| { status: "pending"; previous: boolean }
| { status: "saved" }
| { status: "failed"; previous: boolean; retry: () => void };
With separate isLoading, isError and isSaved flags, nothing stops the interface from being loading and failed at once, and eventually it will show both. A union makes those combinations impossible, and keeping previous around makes the rollback explicit.
Preloading is an interaction design decision
For the "View details" modal, there were three reasonable strategies. [Confirm: which one shipped, and why.]
- Fetch after the click. No wasted requests, but the full latency lands at the worst possible moment.
- Preload during idle time. The content is ready before anyone asks. It also downloads data for modals nobody opens, competes with other work on slower devices, and may be stale by the time it's used.
- Preload on intent. Start when the signal gets stronger: hover, keyboard focus or the pointer going down. The head start is smaller, and so is the waste.
The browser now encodes the same tradeoff for page navigations. In Chrome's Speculation Rules API, the moderate setting starts after a 200 millisecond hover or on pointer down, and conservative waits for pointer down.8
A minimal version of intent preloading:
function preloadOnIntent(trigger: HTMLElement, load: () => Promise<unknown>) {
let request: Promise<unknown> | undefined;
const start = () => {
request ??= load().catch(() => (request = undefined));
};
for (const type of ["pointerenter", "focus", "pointerdown"]) {
trigger.addEventListener(type, start);
}
}
A few details matter more than the snippet suggests. A short hover delay keeps a cursor sweeping across a list from preloading every row.9 Touch screens have no hover, so on mobile the head start shrinks to the gap between touch start and touch end. And it's worth splitting what you preload: the modal's code doesn't go stale, so it can load early, while its data is better fetched closer to use.
Measuring both sides
To compare strategies, I measured both sides of the trade. For responsiveness: time from click to first feedback, time from click to content, and Interaction to Next Paint from real users, broken into input delay, processing and presentation.10 For cost: requests made per modal actually opened, extra bytes per session, and main-thread time spent preloading.
[Confirm after measuring: results for each strategy, with the test device, network profile and sample size.]
Idle preloading will almost always win on time to content. It will also lose on waste. That's the point of measuring both.
Fast is 40 ms and 10 Mbps, slow is 400 ms and 1 Mbps. The details code is 38.4 KB, each show 4.6 KB to 6.8 KB. The CPU setting multiplies the page’s simulated main-thread work, and changing either starts a new session. A filled dot means that show’s data has loaded, a ring that it’s loading.
Fetch after the click
Nothing loads until someone views a show.
- Feedback
- –
- Content
- –
- Requests after click
- –
- Unused so far
- 0 · 0.0 KB
- All requests
- 0 · 0.0 KB
- Preload work
- 0 ms
// loadCode and loadData reuse
// an earlier request.
async function open(id) {
showFrame();
const [view, data] =
await Promise.all([
loadCode(),
loadData(id),
]);
fill(view, data);
}
button.onclick = () => open(id);Preload during idle time
The code and every show load once the page is idle.
- Feedback
- –
- Content
- –
- Requests after click
- –
- Unused so far
- 0 · 0.0 KB
- All requests
- 0 · 0.0 KB
- Preload work
- 0 ms
requestIdleCallback(() => {
loadCode();
ids.forEach(loadData);
});
button.onclick = () => open(id);Preload on intent
Starts on a 100 ms hover, keyboard focus or pointer down.
- Feedback
- –
- Content
- –
- Requests after click
- –
- Unused so far
- 0 · 0.0 KB
- All requests
- 0 · 0.0 KB
- Preload work
- 0 ms
let timer;
const start = () => {
loadCode();
loadData(id);
};
button.onpointerenter = () => {
timer = setTimeout(start, 100);
};
button.onpointerleave = () =>
clearTimeout(timer);
button.onfocus = start;
button.onpointerdown = start;
button.onclick = () => open(id);View a show with each strategy.
Between click and content
When intent preloading works, nothing seems to happen. No one admires a spinner that never appeared. And sometimes the right call is not to optimize at all: if a modal is rarely opened and loads in a couple of hundred milliseconds, the preloading code is a cost with nobody to pay it back.
The principle I keep coming back to: spend resources in proportion to how likely the interaction is, and protect the moment someone asks for something. Protecting it takes more than making it short. Every important interaction should communicate what happened, what is happening and what happens next. If I can't answer those, the screen isn't finished, however good its frames look.
Footnotes
-
Lighthouse's default navigation mode analyzes a cold page load, so there's no interaction to measure. Total Blocking Time stands in as a lab proxy for responsiveness, and timespan mode can capture what happens while you click through a flow yourself. See Lighthouse user flows on web.dev. ↩︎
-
Interaction to Next Paint on web.dev. It replaced First Input Delay as a Core Web Vital in March 2024. First Input Delay only measured the delay before the first interaction's handlers ran; Interaction to Next Paint covers every interaction, all the way to the paint. ↩︎
-
Measured at the 75th percentile of real page loads, with mobile and desktop reported separately. Above 500 milliseconds counts as poor. See Interaction to Next Paint. ↩︎
-
Jakob Nielsen, Response Times: The 3 Important Limits, Nielsen Norman Group. The third limit is about 10 seconds, roughly how long someone will keep their attention on a task before drifting. The thresholds trace back to Robert Miller's 1968 research on response time, which is part of why they've aged so well: they describe people, not hardware. ↩︎
-
That shift is measured too, as Cumulative Layout Shift. A skeleton that's the wrong size can trade a slow interaction for a jumpy one. ↩︎
-
AbortController on MDN. Aborting stops the response from being used, but the server may still do the work, so it doesn't save as much as it seems to on the backend. ↩︎
-
prefers-reduced-motion on MDN. The setting asks for less motion, not none. Opacity changes are usually fine; large movement, zooming and parallax are what tend to cause discomfort. ↩︎
-
Chrome for Developers, Speculation rules improvements. On mobile, where there's no hover,
moderatefalls back to pointer down as well.eagerandimmediatespeculate sooner, withimmediatestarting as soon as the rules are seen. ↩︎ -
The snippet above leaves this out to stay short. In practice I'd start a timer on
pointerenter, clear it onpointerleave, and letfocusandpointerdownstart the load right away, since those are stronger signals. ↩︎ -
The same three parts web.dev uses: input delay (waiting for the main thread before handlers run), processing duration (the handlers themselves) and presentation delay (layout, paint and getting the frame on screen). The web-vitals library's attribution build reports each one from real sessions. ↩︎