On Writing UI That Respects Time
There's a specific kind of frustration that comes from waiting for a UI that doesn't acknowledge you're waiting.
No skeleton. No spinner. No indication that anything is happening. Just a void where content should be. It's rude in the same way a person is rude when they don't look up from their phone while you're talking.
The apologetic spinner trap
Most teams treat loading states as an afterthought — a spinner component copied from a design system, dropped in wherever data might be slow, job done. The problem isn't the spinner itself. It's that spinners apologize for latency rather than working with it.
A well-designed loading state does three things:
- Acknowledges that the user took an action
- Estimates how long the wait will be
- Preserves layout so the content arrival doesn't cause jarring reflows
Skeleton loaders do this better than spinners because they answer the implicit question: what am I waiting for, exactly? A skeleton shaped like a list of cards communicates "your data is coming, and it will look like this."
Progressive disclosure and perceived speed
Here's a technique I started using after reading about the psychology of waiting: render what you have, immediately.
If a page has a header, navigation, and a content zone — render the header and navigation instantly (they're often static), then use a skeleton only for the content zone. The user perceives the page as "mostly loaded" even though the important part is still in flight.
Netflix does this aggressively. YouTube does this. Most product teams don't, because it requires thinking about data fetching topology rather than treating each route as a monolithic fetch.
The 400ms threshold
Under 400ms, users don't need feedback. Over 400ms, they do. Over 4 seconds, they need progress feedback, not just an indication that something is happening.
I set a CSS animation-delay: 300ms on all my skeletons so they only appear if the fetch takes longer than 300ms. For fast connections this means the skeleton is never seen — the content just appears. For slow connections, the skeleton surfaces right before frustration sets in.
const Skeleton = styled.div`
animation: pulse 1.5s ease-in-out infinite;
animation-delay: 300ms;
opacity: 0;
@keyframes pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
`
Small details. Real impact.