Escaping the Loading Spinner

Most "slow" SaaS dashboards weren't slow in the demo. They got slow the moment real data showed up. This is a look at why that happens, what it costs you in adoption and renewals, and the specific frontend decisions that keep heavy, interactive UIs fast under load.

7 min read

The dashboard that tested fine

A founder sends me a screen recording. The dashboard looks great, pixel-for-pixel the Figma file, and then they open the account that actually matters, the one with real history in it, and the screen sits frozen for a few seconds before the spinner even has the decency to appear. "It tested fine," they tell me. And it did. It tested fine because the test account had eleven rows in it.

This is the most common performance problem I get called in for, and almost nobody describes it as a performance problem. They describe it as "the app feels heavy" or "users say it's laggy" or, my favorite, "I think we need a bigger server." The server is usually fine. The slowness was built into the front end months earlier, in a handful of architecture decisions that were completely reasonable when the data was small and quietly fatal once it wasn't.

Let me make the case for why this is worth your attention before I get into the how.

Slow is a business problem long before it's a technical one

A dashboard is a tool people are supposed to live in. When it stutters, they don't file a bug. They just stop opening it. They go back to the spreadsheet, or they ping a colleague instead of looking it up themselves, or they renew at the lowest tier because the product never became part of their day. None of that shows up in your error logs. It shows up in adoption charts six months later, and by then it looks like a marketing problem.

The cost compounds in a specific way for B2B. Your power users, the ones with the most data, are also the ones with the most leverage over a renewal. They are precisely the accounts that hit the slow path first, because slowness scales with how much they've invested in your product. So the worst experience in your app is reserved for your best customers. That is a genuinely bad place to be, and you usually can't see it from the inside, because your own test accounts are small and your laptop is fast.

There's an operational tax too. A sluggish internal tool generates support tickets that are really just "why is this taking so long," it makes onboarding demos risky because you're one large account away from an awkward freeze, and it slowly trains your own team to distrust the numbers on the screen. Speed isn't a feature you add at the end. It's the thing that decides whether the rest of the features get used.

Where the time actually goes

Now the part I enjoy. When a dashboard crawls, the cause is almost always one of four things, and they're all fixable.

The first is fetching everything up front. The page mounts and asks the backend for the full dataset, all of it, then tries to render the whole thing at once. With eleven rows that's instant. With eleven thousand it's a multi-second stall while the browser parses a giant payload and the framework tries to build a DOM node for every single item. The fix is to stop pretending the user needs all of it at once: paginate or stream the data, render only what's visible, and load the rest as they scroll or drill in.

The second is the unvirtualized list. A table or feed that renders one DOM element per record will choke long before the data feels large to a human. The browser is happy to hold a thousand rows in memory. It is not happy to lay out and paint a thousand rows on every scroll. Windowing, or list virtualization, means you only ever render the dozen or so rows actually on screen and recycle them as the user moves. This single change has turned more "we need a rewrite" conversations into "oh, that's it?" than anything else I do.

The third is state that re-renders the world. This is the subtle one. You change a single filter, or one value updates from a live feed, and instead of repainting the one widget that cares, the entire dashboard re-renders, charts and tables and all. It happens when everything reads from one giant shared store, or when expensive calculations run on every render instead of being cached, or when new object references get created on each pass and trick the framework into thinking nothing can be reused. The fix is boring and effective: keep state close to where it's used, derive computed values once and memoize them, and make sure a change in one corner of the screen can't trigger work in three others.

The fourth is doing heavy work on the main thread. The browser has one thread for running your JavaScript and also for responding to clicks, scrolls, and animations. If you parse a large file, sort a big array, or crunch numbers for a chart on that same thread, the interface goes unresponsive for the duration. The user clicks and nothing happens. The answer is to get that work off the critical path: defer it, break it into chunks, or push genuinely heavy computation into a web worker so the UI stays alive while the math runs.

None of this is exotic. It's the standard toolkit for anyone who has shipped interactive UIs at scale, and it works the same whether you're in React or Vue or anything else. I rebuilt the front end of a communication hub for a B2B CRM, the kind of screen where one account might be watching live call logs, a number inventory, and compliance status all at once. When that platform went from fifteen thousand sub-accounts to seventy-five thousand inside a year, "load everything and hope" stopped being an option. The UI held because the heavy surfaces were virtualized, the state was partitioned so a live update touched one widget instead of the page, and the expensive work never ran where it could block a click.

Where the "just ship it" people are right

I want to be fair here, because the optimization instinct can go too far in the other direction. Most dashboards do not need a virtualized, worker-backed, surgically-memoized architecture on day one. If three people use an internal tool and the biggest account has two hundred rows, building it like it's Bloomberg is its own kind of waste. You'll spend a week defending against load that will never arrive, and you'll ship slower for it. Premature optimization is real, and "make it work, then make it fast" is good advice most of the time.

So I'll refine the claim. The goal isn't to optimize everything up front. It's to avoid the three or four decisions that make speed impossible to add later without a rewrite. Fetching strategy, how state is partitioned, and whether your lists can be windowed are foundational. Get those roughly right early and you can stay naive about everything else for a long time. Get them wrong and no amount of last-minute tuning will save you, because the slowness is structural.

The spinner is a decision you already made

The loading spinner is rarely a loading problem. It's a decision you made three architecture choices ago, finally showing up on screen. The data didn't suddenly get too big. The front end was just built in a way that couldn't absorb it, and the spinner is where that shows.

The good news is the reverse is also true. Because the cause is structural and specific, the fix usually is too. Once you know which of the four it was, you're rarely looking at the rewrite everyone's bracing for. More often it's a targeted change to one or two surfaces, and the app that felt heavy starts feeling instant again.

If you've got a dashboard that demos clean and dies under real data, that's a very fixable problem, and usually a quicker one than it looks. Tell me what you're building: your name, your email, and a few lines about where it's slowing down.

Join the newsletter

Be the first to read our articles.