Skip to content
Lauren DormanDesign Engineer

Notes

Between click and content

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

Two builds of the same modal with the same total wait. Try both before reading the timelines.

Actual versus perceived

A single click passes through several kinds of time, and each one is felt differently.

LayerWhat's happeningHow it feels
Network and serverThe request travels and the response is builtInvisible, unless nothing else happens
Rendering and hydrationThe browser draws the page and attaches behaviorA button that's visible but doesn't respond yet
Interaction latencyInput delay, event handlers running, the next frame paintingWhether the press registered
Visual feedbackThe first sign the interface heard youWhether it's working
Perceived responsivenessAll of the above, judged by a personWhether 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.

IdlePendingSavedFailedRolled backpressconfirmedconfirmedpressofflineretrypress againleave pagepresspress again

Idle · no request yet

Interruption

Press save, press it again mid-request or go offline, and the diagram follows the button.

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.]

ApproachBehaviorWhere it worksWhere it fails
ImmediateContent swaps instantlyFrequent, repeated actions, like arrowing through a listSpatial context is lost, and it's easy to miss what changed
DecorativeA generic fade and slideRare moments where tone mattersAdds time without information, and looks awkward when interrupted
ContinuousThe card becomes the panel while the list stays put behind itNavigation where knowing where something went mattersMore 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.

Transition

SavedFive shows

Low Orbit Trio

Room Nine · Thu, Nov 12
Loading details

Tab, ↑ ↓, 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

Switch between the three transitions, then try a slow network, reduced motion, keyboard only, or interrupting halfway.

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 AbortController per 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-motion should 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.]

  1. Fetch after the click. No wasted requests, but the full latency lands at the worst possible moment.
  2. 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.
  3. 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.

Simulated network
Simulated CPU

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.

Open the modal with each strategy, then browse without opening anything and watch the wasted requests add up.

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

  1. 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. ↩︎

  2. 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. ↩︎

  3. 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. ↩︎

  4. 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. ↩︎

  5. 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. ↩︎

  6. 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. ↩︎

  7. 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. ↩︎

  8. Chrome for Developers, Speculation rules improvements. On mobile, where there's no hover, moderate falls back to pointer down as well. eager and immediate speculate sooner, with immediate starting as soon as the rules are seen. ↩︎

  9. The snippet above leaves this out to stay short. In practice I'd start a timer on pointerenter, clear it on pointerleave, and let focus and pointerdown start the load right away, since those are stronger signals. ↩︎

  10. 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. ↩︎