Fast Is a Feeling: Engineering Perceived Performance

- Author
- Richard Le Peurian Cross
- I'm a technical lead with a background in design, front-end development and marketing, focused on building thoughtful digital experiences that balance creativity, usability and solid engineering.
- Published
Users don't experience milliseconds; they experience waiting. How to close the gap between measured and felt speed honestly, and spot loading states that hide slowness.
Two apps, one Lighthouse score
Picture two hypothetical task-management apps. Both score in the high nineties in Lighthouse and both pass Core Web Vitals in the field. In the first, you tick a task and the tick appears as your finger lifts. In the second, you tick a task, a small spinner turns for about a second, the tick arrives, and then the list reflows so that the task you were about to tick next drops a row.
The stopwatch says these apps are roughly equivalent. Anyone who used both would disagree.
Users never see a timing number. They see what the interface does while they wait: whether it acknowledges them, whether it signals what is coming, and whether it keeps its word. That makes perceived performance the engineering of uncertainty. Every loading state is a small promise about how long something will take and how it will turn out.
Users time uncertainty, not requests
Measured performance is what the system does: bytes, render times, round trips. Perceived performance is what the person experiences. You need both. A fast backend behind a confusing interface feels slow, and a well-choreographed interface on a slow backend eventually runs out of choreography.
Operations research reached this point before front-end engineering did. David Maister's essay on the psychology of waiting lines argued that unexplained waits feel longer than explained ones, uncertain waits feel longer than known, finite ones, and unoccupied time drags more than occupied time. Anyone who has stood in a stationary post office queue with no idea why will recognise all three.
Now map that onto a screen. A blank white page is unexplained, uncertain and unoccupied, so it hits all of Maister's aggravating conditions at once. A stable page shell that fills in is an explained wait with a visible end. The milliseconds might be identical, but the experience is very different.
There's a catch, and it separates the honest techniques from the theatrical ones. Perception techniques work because they reduce real uncertainty. A shimmering placeholder that says nothing about what is coming buys some patience on the first visit and none by the fifth. People learn what an interface's signals mean, and they quickly learn which ones are decoration.
That is where the business pays. Felt slowness never appears as a line item. It appears as double-clicked submit buttons, abandoned forms and a quiet conclusion that the product is flaky. Trust and task completion are spent at the moment of waiting.
Core Web Vitals are instruments, not the verdict
Core Web Vitals give teams three field-measured numbers, each aimed at a different facet of experience. Largest Contentful Paint (LCP) covers loading, Interaction to Next Paint (INP) covers responsiveness, and Cumulative Layout Shift (CLS) covers visual stability. According to web.dev, a good experience means LCP within 2.5 seconds, INP of 200 milliseconds or less and CLS of 0.1 or less. These are assessed at the 75th percentile of page loads, segmented across mobile and desktop.
These are good instruments. They measure real devices on real networks, and the 75th percentile stops a team celebrating an average that hides its worst-served users.
Now consider what they can't see in an app-like interface. INP can confirm that the next frame painted quickly after a click, but not whether that frame contained the result or a spinner. None of the three captures how long a five-step task feels. None can tell whether a progress bar is lying.
The metrics openly present themselves as proxies. Web.dev states that Core Web Vitals will evolve over time, and they have. INP replaced First Input Delay as a stable Core Web Vital in 2024 because it addressed runtime responsiveness more comprehensively. FID only measured the delay before the first interaction could be handled. INP looks at interactions throughout the visit. Something once treated as the measure of responsiveness was retired in favour of a better approximation.
Treat Core Web Vitals as smoke detectors. They are excellent at catching regressions in the field and say little about whether the house is pleasant to live in. Goodhart's law applies here: once a measure becomes a target, it stops measuring well. A team chasing LCP can ship a large, quick-loading hero image and nothing a user can actually do. The dashboard turns green while the task stays unfinished.
Three old numbers that keep holding up
Jakob Nielsen's response-time limits predate every JavaScript framework currently being argued about. In his 1993 book Usability Engineering, drawing on Miller (1968) and Card et al. (1991), he set out three rules of thumb:
- about 0.1 seconds feels instantaneous
- about 1 second keeps the user's flow of thought intact, although they notice the delay
- about 10 seconds is the limit for keeping their attention
He treats these as rules of thumb, not physical constants. They have lasted because, in Nielsen's words, the guidelines are about user experience rather than coding: the same advice applies to an application written in C++ or in JavaScript. Hardware has improved by orders of magnitude, while human attention has stayed the same.
The practical move is to turn the limits into a feedback budget:
| Expected wait | What the user needs | Proportionate feedback |
|---|---|---|
| Under 0.1s | Direct manipulation | Show the result; no indicator |
| 0.1s to 1s | Continuity | Acknowledge the input, e.g. a pressed state |
| 1s to 10s | Reassurance | Low-key inline progress |
| Over 10s | An estimate, so they can do something else | Percent-done or running progress |
The proportionality matters as much as the thresholds. Nielsen warns that for operations taking two to ten seconds, a full percent-done bar may be overkill. He says it can violate display inertia, where the screen changes so fast that users can't keep up or feel stressed. Feedback should match the size of the wait. A progress bar that sweeps across in three seconds adds stress, not information.
Optimistic UI: borrowing certainty from the server
Optimistic UI updates the interface before the server confirms the change, then reconciles once it responds. When the request succeeds, which is usually the case, perceived latency drops to roughly the time a re-render takes.
Think of it as a loan. The interface lends the user certainty it doesn't yet have, and it must be able to repay that loan when the server disagrees.
The loan is sensible for frequent, low-stakes, reversible actions: likes, toggles, reordering, saving drafts. It is reckless for payments, deletions without undo, or anything irreversible or legally significant. There, a confident lie that later reverses costs far more than an honest second of waiting. Telling someone their transfer has gone through is a different kind of promise from telling them their star has been added.
React 19 includes a useOptimistic hook for this pattern. It shows a provisional value while an async action is pending and falls back to the real state when the action settles. That handles the display. It doesn't handle repayment.
Paying it back: rollback when the server disagrees
Rollback is where the real engineering effort of optimistic UI sits. First, keep the previous state, so a failure restores something the server actually confirmed, not a reconstruction. Second, show the error inline, beside the affected item. A toast in the corner tends to disappear while the user is three screens away. Third, make retries safe with idempotent requests or idempotency keys, so a nervous double-tap or an automatic retry can't apply the same change twice.
Ordering is the hardest part. Take a hypothetical case: a user renames a task from "Draft brief" to "Draft Q3 brief", then drags it to the top of the list. The reorder succeeds and the rename fails. A naive rollback restores a snapshot of the whole list taken before the rename. That reverts the title, but it also moves the task back to its old position, undoing a change the server accepted. Client and server now disagree, and the user sees their drag silently reversed.
The correct rollback is narrower. It reverts only the field the failed mutation touched, applies the reversion to the latest confirmed state, keeps the new position, and marks the title with an inline error and a retry. If a failure arrives after a newer rename of the same field has already succeeded, the stale failure must not overwrite it. That means tracking which mutation is newest, through per-field versions or a sequence number.
Teams that skip this work haven't built optimistic UI. They've built a fast way to tell users things that aren't true.
Streaming: deciding what earns the first paint
React Suspense lets parts of a component tree show a fallback while their data loads. The Next.js App Router builds on this. It streams server-rendered HTML progressively using Suspense boundaries and loading files, so the page shell and fast content reach the browser before slow data does.
In Maister's terms, streaming turns one long, unexplained wait into several short, explained ones. The navigation, the heading and the user's name appear first. The analytics panel that depends on a slow aggregation query arrives when it's ready, in a space already reserved for it.
The interesting decision is where to put the boundaries, and that is a product decision more than a technical one. Which content earns the first paint? What does someone opening this page need in the first second, and what can arrive later without harm? Use too many boundaries and the page pops in fragment by fragment, each resolving on its own schedule, so layout and attention jump around. Use too few and you are back to the blank-screen wait.
Streaming fixes delivery order. It doesn't fix the slow query. A three-second database call still takes three seconds, but at least it now takes them in a box that explains itself.
Loading states on trial: design solution or cover-up?
A loading state is a design solution when the wait is genuinely unavoidable and the state explains it. It is a cover-up when it puts off fixing a slow query, a request waterfall or an oversized bundle.
Spinners are the usual suspects. Nielsen treats a generic busy indicator as a last resort, because it shows that the system is working without saying what it is doing. For long operations of unknown size, he suggests running progress that names the work as it happens, such as listing each source as it is searched.
Fake progress bars are worse. They crawl to 90% and stall, timed by a guess instead of by work done. They exist because nobody could measure real progress, and often that is because nobody has looked at where the time goes.
Skeleton screens can go either way. A skeleton that reserves the accurate dimensions of the incoming content is honest: it shows the shape of what is coming and keeps the layout still. A generic skeleton that doesn't match the final layout causes layout shift when the real content lands. That hurts CLS and teaches users that the placeholder can't be trusted.
Timing is part of the craft. A short delay before showing any indicator avoids a flash for responses that arrive quickly. A minimum display time stops a spinner flickering for a single frame. Nielsen makes a related point: animations should be timed by a real-time clock, not by execution speed, because an interface can move too fast for people to follow.
A short honesty test for any loading state
Apply these in a design review:
- Does it say what is happening, or only that something is?
- Does it match the shape and size of what will arrive?
- Is it proportionate to the expected wait?
- If we removed it, would we have to fix something? If so, schedule that fix.
- When the operation fails, does it recover visibly and in place?
A loading state is a promise
Every loading state promises something about time and outcome: this will take a moment, this will look like that, this has worked. If a team can't say what a particular state promises, it is probably hiding something.
So put loading states on the same review agenda as API latency. They are two halves of one decision, and they are usually made by different people in different meetings. The apps that feel fastest are rarely the ones with the most impressive animation budget. More often, they are the ones that told users the truth sooner.