Why We Replaced Spinners With Skeleton Screens on a Recent Client Build
A few months ago we shipped a dashboard rebuild for a client whose users were filing tickets that all said some version of the same thing: "the app feels slower than it used to." The odd part was that our own performance instrumentation said the opposite. Median load time had actually dropped by about 15% compared to the previous version.
We spent a week convinced the metrics were lying before we figured out the metrics were fine. The interface was lying.
Where We Started Looking
The first instinct on a project like this is to distrust the monitoring, so we spent the first couple of days re-verifying our own numbers before we trusted them. Real user monitoring, synthetic tests from three different regions, server-side timing logs, all of it agreed: things had gotten faster, not slower, on every dimension we were tracking.
That's a genuinely uncomfortable place to be as an engineering team, because "the users are wrong" is rarely the right conclusion, and "our metrics are lying" was demonstrably false once we'd checked it three different ways. The only remaining explanation was that we were measuring the wrong thing entirely, that the users' experience of speed and our instrumentation of speed had quietly diverged.
What Was Actually Happening
The old dashboard had a full-page spinner that appeared the instant you navigated to it and stayed until every widget on the page had finished loading, sometimes six or seven separate data fetches. The new version was objectively faster in aggregate, but it rendered its first content later relative to when the spinner appeared, because we'd restructured the data fetching to be more efficient in total but less front-loaded.
Users don't experience total time. They experience the interval between "I did something" and "I saw something happen." We'd made the first number better and the second number worse, and the second number was the one people actually felt.
The Fix Wasn't a Backend Change
We replaced the full-page spinner with a skeleton layout matching the dashboard's actual grid: placeholder cards in the exact positions the real widgets would occupy, each one resolving to real content independently as its data arrived, instead of waiting for all seven fetches to finish together.
This meant the page started looking "alive" within a few hundred milliseconds, even though the slowest widget on the page still took the same amount of time it always had. Nothing about the backend changed. What changed was that the interface stopped hiding its own progress from the user.
Nielsen Norman Group's research on response time thresholds was a useful gut check while we made this call. The window between one and ten seconds, where most of these individual widget loads fell, is exactly the range where interface design determines whether a wait feels managed or ignored.
What We'd Do Differently Next Time
The one thing we underestimated was how closely the skeleton shapes needed to match the real content. Our first pass used generic rectangular placeholders that didn't account for the fact that one widget rendered a chart and another rendered a short text summary. When the real content swapped in, the layout shifted more than we expected, which reintroduced a smaller version of the same "something feels off" complaint we were trying to fix.
The second pass matched the skeleton shapes to the actual rendered dimensions of each widget type, checked against web.dev's Cumulative Layout Shift guidance, and that fixed it. It's a detail that's easy to skip under deadline pressure and expensive to skip in practice.
How We Verified It Actually Worked
We didn't just ship the skeleton version and assume the problem was solved. We watched a handful of session recordings from before and after the change, specifically looking at what users did in the first two seconds after navigating to the dashboard. Before the change, a meaningful number of sessions showed a click on the browser refresh button within the first second, a strong signal that the blank spinner state read as "nothing is happening" rather than "loading."
After the change, that refresh-click pattern dropped close to zero. Nobody was refreshing a page that was visibly already rendering content, even partial, placeholder content. That single behavioral signal told us more about whether the fix worked than any aggregate timing metric did, because it captured exactly the thing we were trying to fix: the moment of uncertainty where a user decides whether to trust that something is happening.
Ticket volume mentioning "slow" or "frozen" dropped by roughly half over the following month, without a single additional backend change. The lesson generalized past this one project: for any product where users describe something as feeling slow despite metrics saying otherwise, the fix is very often in how the wait is communicated, not in the wait itself.
The Part That Took Longer Than Expected
Building the skeleton layout itself took about two days. Getting the skeleton shapes to actually match the real widget dimensions took closer to a week, because it meant going back through every widget type in the dashboard, measuring its real rendered dimensions across a range of realistic data (a chart widget with three data series looks different from one with twelve), and building a matching placeholder for each variant rather than one generic block reused everywhere.
That ratio surprised us. We'd budgeted the project assuming the skeleton implementation itself was the hard part. It turned out the shimmer animation and placeholder markup were trivial. The actual work was in the audit: figuring out what the real content shapes looked like across enough realistic scenarios that the skeleton didn't introduce its own layout shift once real data arrived. If you're estimating a similar project, budget the audit time separately from the implementation time. They're genuinely different kinds of work, and treating them as one estimate is how this kind of project ends up running over its original timeline without anyone being able to point to a specific reason why.
"The lesson that keeps repeating on projects like this is that perceived speed and actual speed are two different engineering problems, and most teams only budget time for one of them." - Dennis Traina, founder of 137Foundry
A few weeks after this project wrapped, a second client asked us to look at a strikingly similar problem on a completely different codebase, a mobile-first booking flow where the reported complaint was "the app hangs when you tap search." Same root cause, a blank screen during a genuinely brief wait, same fix, a skeleton preview of the results list shape. It's become one of the more reliable diagnostic shortcuts we reach for now: when the metrics and the complaints disagree, check what the user actually sees during the wait before assuming either one is wrong.
We wrote up the fuller framework, when to use a skeleton versus a spinner versus a real progress bar, in a longer guide if you want the details beyond this one project: designing loading states that don't feel like lying to users. And if your product has this exact "the metrics say fast but users say slow" gap, it's usually a loading state problem before it's anything else, which is the kind of front-end work we do a lot of at 137Foundry.
























