A spinner tells the user exactly one thing: something is happening. It doesn't say what, how long, or whether it's almost done. For a 200ms request that's fine - nobody notices a spinner that blinks and disappears. For anything longer, it's the interface equivalent of being put on hold with no elevator music.
Three speeds, three responses
Most loading states fall into one of three buckets, and each one wants a different treatment:
- Under ~300ms. Show nothing at all. A loading state that flashes for a tenth of a second reads as a glitch, not information - it draws the eye without giving it anything useful to look at.
- Roughly 300ms to a few seconds. Show a skeleton shaped like the content that's coming - the same card grid, the same row heights - so the layout doesn't jump when the real data lands. This is the range a spinner handles worst and a skeleton handles best.
- Longer, or genuinely unbounded (an export, an upload, a batch job). Here a spinner is honest again, but only if it's paired with a sentence about what's happening and, where you can manage it, real progress.
Why the skeleton wins the middle
A skeleton screen works because it sets an expectation before the data exists. The eye already knows roughly where the title, the avatar, and the price will land, so when they resolve it feels like the page finishing a sentence rather than a page rearranging itself underneath the cursor. That's most of what "perceived speed" actually is - not a faster server, just fewer surprises.
There's a measurable side to this too. Cumulative Layout Shift is one of the Core Web Vitals, and it specifically penalizes content that jumps around after it renders. A skeleton that reserves the same width and height as the real card, image, or row contributes close to zero shift when it's replaced - the box was already there, only its contents changed. Popping content into a page with no placeholder at all is one of the more common ways sites quietly fail that metric.
The trap is showing a skeleton immediately, for everything, on every request - including the fast ones. A guard against that is worth the five lines it costs:
import { useEffect, useState } from "react";
// Only flips to true if `loading` is still true after the delay -
// so a fast response never shows a skeleton at all.
export function useDelayedLoading(loading: boolean, delayMs = 300) {
const [showSkeleton, setShowSkeleton] = useState(false);
useEffect(() => {
if (!loading) {
setShowSkeleton(false);
return;
}
const timer = setTimeout(() => setShowSkeleton(true), delayMs);
return () => clearTimeout(timer);
}, [loading, delayMs]);
return showSkeleton;
}Sometimes the right loading state is none at all
For a certain class of action - toggling a checkbox, liking a post, renaming something inline - the request will very likely succeed, and waiting for the server to confirm that before updating the UI just adds a delay the user has no reason to tolerate. Optimistic updates flip the order: update the interface immediately, send the request in the background, and roll back only if it actually fails. React 19 folds this pattern into a built-in hook rather than leaving it to a data-fetching library:
import { useOptimistic } from "react";
function TaskItem({ task, onToggle }: { task: Task; onToggle: (id: string) => Promise<void> }) {
const [optimisticDone, setOptimisticDone] = useOptimistic(task.done);
async function handleToggle() {
setOptimisticDone(!optimisticDone);
await onToggle(task.id); // rolls back automatically if this throws
}
return (
<label>
<input type="checkbox" checked={optimisticDone} onChange={handleToggle} />
{task.title}
</label>
);
}This isn't a replacement for skeletons and spinners - it only makes sense when failure is rare and recoverable. Nobody wants to see "payment successful" flash on screen for half a second before it reverts. But for the small, frequent, low-stakes actions that make up most of a dashboard's interactions, skipping the loading state entirely is often the more honest choice, not a shortcut.
AppCrumble's SkeletonLoader follows the same split for everything that can't be optimistic - instant for the genuinely fast paths, shaped and delayed for everything else. It's a small piece of the interface, but it's one of the few that users feel on every single page load, whether they notice it or not.