A team ships a p50 server time of 90ms, watches the dashboard go green, and keeps hearing that the product feels slow. The instinct is to distrust the users or hunt for a slower endpoint. Usually neither is the problem. The number on the dashboard and the feeling in the room are measuring two different intervals, and only one of them is the product.
Perceived duration starts when a person decides to act and ends when they can see the result of that decision. Your backend metric starts when the client calls fetch and ends when the last byte arrives. Between those two boundaries sit the event handler, a re-render, a layout pass, a font swap, an image decode, and whatever animation someone added to make it feel premium. All of it lands on the user’s side of the interval and none of it lands on yours.
Three thresholds, and what each one obliges you to do
The human-factors numbers here have been stable for decades and they are still the most useful design constraint in the toolbox, because each threshold changes what the interface owes the user.
| Budget | What the user experiences | What the UI must do |
|---|---|---|
| Under ~100ms | The result feels caused by their action | Nothing. Do not add a spinner. A spinner here invents latency. |
| ~100ms to ~1s | A noticeable delay, but attention holds | Show the shape of what is coming. No progress claim. |
| ~1s to ~10s | Attention starts leaving the task | Show real progress and keep the rest of the page usable. |
| Over ~10s | The user has mentally left | Stop pretending it is synchronous. Hand it off and tell them when it lands. |
The most common mistake is one row up from where teams look: putting a loading indicator on a 60ms interaction. The indicator has to mount, paint, and unmount, so it converts an instant action into a visible flash of machinery. If you cannot guarantee under 100ms, that flash is honest. If you can, it is self-sabotage.
p99 is the trust number
Teams optimise p50 because it moves easily and looks good in a review. Users do not experience percentiles one at a time; they experience a session made of many interactions, and the tail is what they remember.
Take a session with twenty requests. The chance of avoiding your p99 on every single one is 0.99 raised to the twentieth power, about 0.82. So roughly 18 percent of sessions contain at least one p99 event. Push it to fifty requests, which is an ordinary afternoon in a real application, and it is 39 percent. Two users in five hit your worst case, and your median stayed green the whole time.
Nobody forms an opinion about your median. They form it about the one time the thing hung.
This is also why an average is worse than useless in this domain. A 90ms mean can be 80ms for everyone plus four seconds for the person whose account has ten thousand rows, and that person is usually your best customer.
Choosing between spinner, skeleton, and optimistic
These three are not interchangeable styles. Each one makes a different promise, and the right choice falls out of two questions asked in order.
Do you know the shape of the result before it arrives? If yes, use a skeleton, because it lets the reader start parsing the layout while the data is in flight and it prevents the content shift that arrives with the payload. If no, a skeleton is a lie about the structure and a spinner is more honest.
Can you predict the result, and is the action reversible? If both, go optimistic: render the new state immediately and reconcile when the server answers. Toggling a flag, renaming a thing, adding an item to a list you already hold. If the result is unpredictable, or the action is not reversible in the user’s mind, do not fake it. An optimistic payment confirmation that turns out to be false is not a performance win, it is a broken promise with a fast render time.
Optimistic updates are a state machine, not a shortcut
Most optimistic UI bugs come from treating it as “set the value early” rather than as three states with a defined transition out of each. Written out, the honest version is not much code, and the idempotency key is not optional: without it, a retry after a timeout can apply the mutation twice.
// Every pending mutation keeps the value it replaced, so a failure
// can restore exactly what the user was looking at before.
function reducer(state, action) {
switch (action.type) {
case "apply":
return {
...state,
value: action.next,
pending: { key: action.key, previous: state.value },
};
case "confirmed":
if (state.pending?.key !== action.key) return state; // stale reply
return { ...state, value: action.server, pending: null };
case "failed":
if (state.pending?.key !== action.key) return state;
return {
...state,
value: state.pending.previous,
pending: null,
notice: action.reason,
};
}
}
async function commit(dispatch, next, previous) {
const key = crypto.randomUUID();
dispatch({ type: "apply", next, key });
try {
const server = await save(next, { idempotencyKey: key });
dispatch({ type: "confirmed", key, server });
} catch (e) {
dispatch({ type: "failed", key, reason: describe(e) });
}
}
Two details in there earn their keep. The key check on every resolution discards replies for a mutation the user has already superseded, which is the fix for the classic bug where a slow first response overwrites a fast second edit. And previous is stored per mutation rather than read from current state at failure time, because by then the state has moved.
The rollback is the design work
Here is the part that usually ships unfinished. An optimistic update that fails has to undo something the user believes already happened, and by then they have scrolled, navigated, or started the next task. A toast in the corner saying “could not save” is not a recovery path. It is a notification that some unspecified thing they did is now untrue.
A rollback that respects the user does three things: it names the specific item, it keeps their input rather than discarding it, and it offers the retry in place. “Renaming to Q3 forecast failed, the network dropped. Retry” is a recovery. “Something went wrong” is an apology with no next step. If you are not willing to build that path, you have not earned the optimistic render.
Measure the interval the user lives in
If perceived latency is the thing that matters, instrument it directly. Start at the input event, not at the request, and stop after the browser has actually painted the result. Everything in between is included whether you own it or not, which is the point.
function trackInteraction(name, event, run) {
// event.timeStamp is when the input happened, which may be well before
// the handler got a turn on a busy main thread.
const start = event.timeStamp;
return run().finally(() => {
requestAnimationFrame(() => {
// Runs after the frame that shows the result has been composited.
requestAnimationFrame(() => {
report(name, performance.now() - start);
});
});
});
}
Using event.timeStamp instead of performance.now() inside the handler is deliberate. If the main thread was busy when the user clicked, the gap between the click and your handler running is real waiting, and starting the clock in the handler hides exactly the delay users complain about most.
Report it as a histogram, not a mean, and slice by the dimensions that produce tails: account size, device class, connection type, cold versus warm cache. A single global p99 tells you a tail exists. Sliced, it tells you it belongs to enterprise accounts on mid-range Android devices, which is a bug you can actually assign.
Empty and error states are latency states
One more thing that gets designed last and matters early. “Loading,” “loaded but empty,” and “failed” look similar in a wireframe and feel nothing alike. When they are rendered with the same neutral placeholder, a user cannot tell whether to wait, to act, or to retry, and the interface has converted a two-second delay into an open question.
Make them visibly different. A skeleton that animates says wait. A stated empty state with the action that fills it says act. An error that names the cause and offers a retry says try again. That distinction costs almost nothing to build and removes most of the anxiety that people describe, imprecisely, as slowness.
The short version
- Measure from the input event to the painted frame. Anything narrower is measuring your infrastructure, not your product.
- Optimise the tail. Sessions multiply your p99 into a coin flip.
- Under 100ms, show nothing. A spinner there manufactures the delay it was meant to explain.
- Go optimistic only where the result is predictable and the action is reversible, and build the named, in-place rollback before you ship the fast render.
- Give loading, empty, and failed three visibly different faces.
None of this requires a faster backend. It requires deciding that the interval the user experiences is the one you are accountable for, and then measuring that instead of the one that was easier to collect.

