Skip to content
All articles

The Admin Table Is the Hardest Screen in Your Dashboard

9 min read

Every admin dashboard is mostly one screen repeated: a list of things, with filters, a search box, sortable columns and pagination. Orders, users, products, vendors, invoices. It looks like the easy part of the build, and it's estimated accordingly.

It is not the easy part. It's the screen that decides whether the dashboard is usable in two years, because it's the only one whose cost grows with the business. Everything else stays the same size while this one gets slower every month.

The mistake that decides everything else

The first version fetches all the rows and filters them in the browser. Of course it does — it's simpler, it's instant, and with 200 records it's genuinely the right call.

The problem isn't that it's wrong. It's that it doesn't degrade gracefully. There's no point where it gets a bit slow and someone files a ticket; it's fine, it's fine, it's fine, and then a customer with 80,000 orders opens the page and the tab locks up. Because the failure is sudden, it always arrives as an emergency, and the fix is not a tweak — filtering, sorting and pagination all have to move to the server together, and that means rewriting the component and the endpoint at once.

So the decision worth making early is not "which approach is faster" but "will this data set ever be large". If the answer is yes or maybe, put filtering on the server on day one, when it costs an afternoon.

Do it in the right order, in the query

Once it's server-side, the order of operations matters and getting it wrong produces bugs that look like data corruption.

Filter, then sort, then paginate — all in the database. The wrong version is common enough to have a signature: fetch a page of rows, then sort those rows. Now sorting only orders the twenty records you happened to fetch, so page two contains rows that should have been on page one, and the same record appears twice while another never appears at all. It reads as a data problem and it's an ordering problem.

And every one of those operations has to run against a query that is already scoped to what this user may see. Scoping in the component — fetch everything, render the permitted rows — breaks the moment you add an export or a new sort parameter, which is the argument I've made at more length elsewhere. On a table screen it's especially easy to get wrong, because the table is usually the first thing built and the export is bolted on later by someone else.

Offset pagination drifts

LIMIT 20 OFFSET 40 is the obvious implementation and it's fine for a static data set. On a table where rows are being inserted while someone reads — which is every orders screen — it quietly misbehaves.

Sort newest first, read page one, and while you're reading, three new orders arrive. Ask for page two and everything has shifted down by three: you see three rows you have already seen, and three you should have seen on page one are now on page zero, which doesn't exist. Nothing errors. Someone processing orders sequentially just skips three, and finds out later.

Cursor pagination fixes it: instead of "skip 40", ask for "the 20 after this record", keyed on the sort column plus a unique tiebreaker. Insertions elsewhere don't move your position.

The tiebreaker is not optional. Sorting by a non-unique column — a date, a status — leaves ties in an order the database is free to change between queries, so the cursor can land mid-tie and skip or repeat. Append the primary key to every sort to make the ordering total.

The trade is that cursors can't jump to page 47. In practice nobody wants page 47 — they want to find a record, which is a search problem. Offer good filters instead of deep page numbers.

The row count is often the slowest thing on the screen

"Showing 1–20 of 84,201" seems free. It isn't: producing that number means counting every row matching the filter, which on a large filtered table can cost more than fetching the page you're actually displaying.

Ask what it's for. Usually it's reassurance rather than information — nobody acts differently at 84,201 than at 84,000. Options, roughly in order of how much I'd reach for them: drop the total and say whether there's a next page; show an approximate count from table statistics; cap it ("999+"); or cache it. Any of those beats making every page load wait on a full count nobody reads.

Put the state in the URL

Filters, sort, page and search belong in the query string, not in component state.

This is the single change that most improves how an admin screen feels to use, and it costs almost nothing. It means a filtered view can be sent to a colleague. It means the back button works after clicking into a record — instead of dumping the user at an unfiltered page one, which is the single most-complained-about behaviour in internal tools. It means a refresh doesn't lose ten seconds of setup, and it means a support person can bookmark the query they run every morning.

It also makes bug reports tractable. "It's wrong" plus a URL is reproducible; "it's wrong" plus a screenshot of some state you can't reconstruct is not.

Search needs a debounce and a race guard

Firing a request per keystroke is obviously wasteful, and debouncing is the well-known fix. The less-known half is that debouncing doesn't prevent the actual bug.

Two requests are in flight; the earlier one returns last; the table now shows results for a prefix of what's in the box. It's intermittent, it depends on network timing, and it will not reproduce on your machine. Either abort the previous request when a new one starts, or tag responses and discard any that aren't for the current query. Most data libraries do this for you — but only if the query key includes the search term, which is exactly the detail people forget.

Virtualisation is not the first answer

Rendering ten thousand rows and virtualising the DOM is a real technique with a real use case: the user genuinely needs to scroll a large set continuously, as in a log viewer.

It is not the fix for a slow admin table, because it addresses rendering while your problem is the query and the payload. You've still fetched ten thousand rows over the network and put them in memory. And it costs you things you'll miss: browser find, straightforward keyboard navigation, and printing. Fix the data path first, and reach for virtualisation only when the user's actual task is continuous scrolling.

The states people skip

Two, and both are what separate a table that feels finished from one that doesn't.

Empty needs to distinguish "none yet" from "none matching". A new account with no orders and a filter combination with no results are completely different situations, and showing the same "No results" to both is why users think a working filter is a broken page. The second should say what's filtered and offer to clear it.

Refetching is not loading. When someone changes a filter, don't replace the table with a spinner — the layout collapses and the page jumps. Keep the rows, dim them, put the loading state on the table rather than instead of it. This is a small thing that makes an admin tool feel solid, and it's noticeable precisely because so few get it right.

Why it's worth the attention

This screen is where the people who use your software spend their working day. A support agent opens the orders table hundreds of times a week; two seconds of latency and a back button that loses their filters is a tax on every one of those.

Nobody demos it. It's the least interesting screen in the product, and it's the one that determines whether the product is pleasant to work in — which is the actual criterion internal tools are judged on by the only people who matter.