Make Slow Feel Fast With Optimistic UI and Smart Skeletons
Skeletons, optimistic UI, and streaming make a product feel twice as fast on the same backend, if you build the rollback as carefully as the happy path.
A button gets tapped. For the next 800 milliseconds, nothing visible happens while a request crosses the network, hits your database, and comes back. To the person holding the phone, that 800 milliseconds is the product. They do not see your query plan or your CDN. They see a screen that froze, and they wonder if they broke it.
Perceived performance is the art of making that gap feel like progress instead of a stall. You can spend a month shaving real latency and gain 200ms. Or you can change what the screen does during the wait and feel twice as fast on the exact same backend. Both are worth doing. This piece is about the second one, because it is cheaper, it ships faster, and most teams ignore it.
Why the wait feels worse than it is
Humans do not measure wait time with a stopwatch. We measure it with attention. A blank screen with a spinning circle gives the brain nothing to chew on, so every second looks identical to the last and the wait stretches in memory. A screen that shows the shape of what is coming gives the brain structure to start processing, and the same elapsed time reads as shorter.
The research here is real but it deserves an honest caveat. Some usability studies report that skeleton screens are perceived as faster than spinners, which is why Google, Medium, and Slack adopted them. But the evidence is not one-sided. Viget's well-known 2017 test ran on mobile with 136 participants comparing a spinner, a skeleton, and a blank screen of identical real duration, and the skeleton screen actually performed worst on perceived duration. The takeaway is not "skeletons always win" and it is not "skeletons always lose." It is that the right loading state depends on context, and you should test it on your own users on real devices rather than copy a pattern because it looks modern.
Skeletons that work, and skeletons that lie
A good skeleton is a low-fidelity preview of the real layout. It reserves the exact space the content will occupy, so when data arrives there is no jarring reflow, no buttons jumping under a thumb mid-tap, the same space-reservation discipline that drives cumulative layout shift to zero on dynamic pages. Facebook's approach is the model: represent the primary structural elements with placeholders, skip the secondary detail to keep it simple, and animate each placeholder with a soft pulse, staggering the start points so the screen feels alive rather than mechanical.
A bad skeleton is one that does not match what loads. If your skeleton shows three cards and the response returns one, you have replaced a spinner with a flicker. Worse, an indefinite skeleton that sits there for ten seconds because a request hung is more frustrating than a spinner, because it promised content that never came. Skeletons set an expectation. Break that expectation and you spend the trust you were trying to build. A well-designed app shell takes this further: the structural chrome paints instantly and only the data regions wear skeletons, so the user never stares at a blank page.
A few rules that hold up in production:
- Match the skeleton geometry to the real component, including the number of rows you typically render.
- Keep the shimmer subtle. A pulse cycle in the 300 to 700 millisecond range reads as ambient motion, not a strobe.
- Put a ceiling on it. If the request has not returned in a few seconds, transition to a clear error or retry state. A skeleton is not a place to hide a failure.
- Respect
prefers-reduced-motion. Replace the shimmer with a static muted block for users who asked for less animation.
Optimistic UI: act first, reconcile later
Skeletons make reads feel fast. Optimistic UI makes writes feel instant. The pattern is simple to state and easy to get wrong: when the user takes an action you are confident will succeed, update the interface immediately as if it already did, fire the request in the background, and reconcile when the real answer comes back.
A like button is the textbook case. The user taps, the heart fills and the count increments the instant the tap registers, and the POST goes out behind the scenes. Ninety-nine times out of a hundred it succeeds and the user never knew there was a network involved. The hundredth time it fails, you roll the heart back and surface a quiet message, and the wording of that message matters as much as the rollback, because an error message can recover trust instead of losing the customer. The user briefly saw a state that turned out to be false, which is a far better trade than making everyone wait on every interaction for the failure case that rarely happens.
The discipline lives entirely in the failure path. Optimistic UI without rollback is just a bug that happens to look right most of the time.
async function toggleLike(post) {
const previous = { liked: post.liked, count: post.count };
// Apply the optimistic state immediately.
setPost({ ...post, liked: !post.liked, count: post.count + (post.liked ? -1 : 1) });
try {
await api.setLike(post.id, !previous.liked);
} catch (err) {
// Reconcile: restore the known-good state and tell the user.
setPost({ ...post, ...previous });
toast("Could not update. Tap to retry.");
}
}
Where optimistic UI fits and where it does not
Use it for actions that are reversible, idempotent, and almost always succeed: likes, follows, marking read, reordering a list, toggling a setting. Avoid it for actions where a wrong intermediate state is dangerous or confusing: a payment confirmation, a destructive delete, anything that triggers an email or charge. The user should never see "payment complete" optimistically and then watch it un-complete. For those flows, a clear busy state and a real confirmation are the honest choice.
Concurrency is the trap that bites teams late. If two optimistic writes to the same record are in flight and one fails, you must restore the correct prior state, not the state from before both writes. Capture the snapshot per action, key reconciliation to the specific request, and never assume requests complete in the order you sent them. The same discipline that keeps agent tool calls idempotent before they double-charge a customer applies here: a retried or out-of-order write must not corrupt the record. This kind of "right intent, wrong reality" bug is invisible in code review and obvious the moment a real user double-taps on a slow connection, which is exactly why we exercise these paths in a real browser before we call a feature done. When we build web applications, the failure path of every optimistic action is part of the spec, not an afterthought.
Streaming and progressive reveal
The third lever is to stop treating a page as all-or-nothing. If the top of the screen depends on a fast query and the bottom depends on a slow one, do not block the whole render on the slow part. Send the fast content immediately and stream the rest in as it resolves. The user starts reading and scrolling while the expensive data is still being fetched, and the slow query becomes invisible because it lands below where their eyes are. The same instant-from-cache trick lives in picking the service worker caching strategy that fits your app: serve what you have now, refresh in the background.
Modern frameworks make this practical with server-side streaming and suspense boundaries. The mental shift is to think in terms of "what can I show now" rather than "what do I have to wait for." Wrap the slow region in its own boundary with its own skeleton, and the rest of the page never pays for it. This is the same lever that slashes time to first byte with streaming server rendering, and it pairs naturally with server components that fetch data without the waterfall tax so the fast content is never blocked behind a chain of dependent requests.
Measure the thing your users actually feel
Lab numbers and felt experience are not the same metric, and the gap between them is where perceived-performance work lives. Largest Contentful Paint tells you when the biggest element painted. Interaction to Next Paint tells you how quickly the interface responds to a tap. These are the numbers that correlate with what a person standing at a bus stop on a patchy connection actually experiences. Track them in the field, not just in a synthetic test on fast office wifi, because the office connection is the one environment your customers are never in.
When we tune a product for speed, we pair real-vitals telemetry with this perceived layer. A page can hit every Core Web Vitals threshold and still feel sluggish if every tap stares back at the user before reacting, which is exactly why field data tells the truth your Lighthouse score hides, and cutting INP below 200ms is what makes a tap feel instant. A page can also have mediocre raw timings yet feel quick because it always shows the shape of what is coming and never makes a write block on the network. The felt experience and the revenue impact of every slow second move together. The websites and apps we ship are tuned for both, because the customer pays you based on how fast it feels, not what your monitoring dashboard says.
The short version
Slow is partly a backend problem and partly a story you tell while the backend works. Skeletons turn a frozen screen into a preview, but only if they match the real layout and never outstay the data. Optimistic UI turns a writing wait into an instant response, but only if you build the rollback as carefully as the happy path. Streaming turns a slow page into a fast one by refusing to make the quick parts wait on the slow ones.
None of this replaces real performance work. It buys you the trust to keep that work funded, because a product that feels fast gets used, and a product that gets used is the one worth making genuinely fast.






