The thresholds, from research that predates the web
These come from Jakob Nielsen’s response-time limits, themselves built on work from the 1960s, and they have not moved because people have not changed. Everything below is about which side of a line to land on.
| Delay | What the user experiences |
|---|---|
| ≤ 100 ms | Instant. The response feels caused by the tap |
| ≤ 1 s | Uninterrupted. They notice the delay but stay in flow |
| ≤ 10 s | They are waiting, and need to be told something is happening |
| > 10 s | Attention is gone. They switch tabs — and may not come back |
Acknowledge in one frame, finish later
This is optimistic UI: assume success, because success is what happens 99 times in 100, and make the hundredth case an honest, visible rollback. The rule is that a rollback must never be silent — the user has to know the thing they saw did not happen.
button.addEventListener("click", async () => {
button.disabled = true; /* 1. instant, visible acknowledgement */
addToList(item); /* 2. show the result as if it worked */
try {
await save(item); /* 3. the slow part, after the paint */
} catch {
removeFromList(item); /* 4. and undo it honestly if it failed */
showError();
} finally {
button.disabled = false;
}
});
The right kind of waiting
A skeleton beats a spinner because it is honest about shape: it tells the user what is coming and reserves the space it will need. A spinner tells them only that you are busy.
And an indeterminate bar that moves is perceived as faster than a static one, even at identical durations — motion reads as progress.
| Expected wait | Show |
|---|---|
| Under 300 ms | Nothing. A spinner that flashes for 200 ms makes it feel slower, not faster |
| 300 ms – 1 s | An inline state: the button in a pending style, a subtle shimmer |
| 1 – 5 s | A skeleton that matches the real layout — it also prevents the shift when content arrives |
| Over 5 s | Real progress: a percentage, a count, or the item currently being processed |
Start before they ask
eagerness: "moderate" fires on hover or on pointer-down — about 200 ms of warning, which is enough to make a navigation feel instant. Prefetching on hover has been the trick behind "instant" sites for a decade; this is the standardised version, and it can prerender the whole page, not just fetch it.
Use it carefully: a prerender runs the page’s scripts. Do not prerender something that charges a card or counts as a view.
<script type="speculationrules">
{ "prerender": [{ "where": { "href_matches": "/product/*" }, "eagerness": "moderate" }] }
</script>
The back button should be free
The back/forward cache keeps the whole page — DOM, JavaScript heap, scroll position — frozen in memory, so going back is instant rather than a fresh load. It is the single biggest perceived-performance feature in modern browsers, and it is easy to accidentally disable.
The blockers: an unload handler (use pagehide), cache-control: no-store on the document, an open connection the browser will not freeze, or being opened by window.opener. Chrome and Firefox both report the reason in DevTools — check it, because most sites break it without knowing.