Somewhere in our team’s internal docs there’s a line I’ve probably typed a hundred times: every list, grid, table and data method has to work at 2,000+ rows on day one. Not “we’ll optimise later.” Not “let’s see how it performs and revisit.” Day one.

I keep saying it because I keep watching the alternative happen, and the alternative is always the same story: a feature looks fast in the demo, ships, and then twelve months later someone’s site has real data in it and the feature quietly stops working. Nobody files a bug that says “your query has no index.” They just say the page is slow, or the plugin feels broken, or they stop using it.

The five-row lie

Here’s the trap, and it’s a genuinely sneaky one. When you build a list view, you build it against your own test data. Your own test data has five rows in it, maybe twenty if you were feeling thorough that day.

Five rows pass every performance check by accident. A full table scan on five rows is instant. A per-row query inside a loop on five rows is five extra queries, which nobody notices. Loading the whole table into PHP memory just to count it is nothing when the table has five rows in it.

None of that tells you anything about whether the code is correct. It tells you the code is small. Those are different properties, and it took me longer than I’d like to admit, earlier in my career, to stop conflating them.

We’ve been burned by this exact gap before, and not always on the performance side of the ledger. When I wrote about the run of releases that were mostly fixing money bugs (five releases, mostly fixing money), the common thread wasn’t slow queries. It was code that had only ever been exercised against clean, small, well-behaved data.

Real customer data is messier and bigger than anything we test with by default, and it finds the seams in your logic the same way it finds the seams in your indexes. A checklist that only covers performance and misses correctness is half a checklist. Ours tries to cover both, because at scale they’re the same failure mode wearing different clothes: code that was only ever proven against a demo.

So a few years back our team sat down and wrote out an actual checklist. Not a vibe, not “keep performance in mind,” an actual eleven-item list that gets run, literally, against every list view, grid, table and archive page before we call it done.

It lives in our internal standards now and it applies to every plugin we touch, old or new. I want to walk through what’s actually on it, because I think most of it generalises past our own stack.

The checklist itself

Here’s the full list in table form first, because I want you to see the shape of it before I go through each one. Notice that only about a third of these are strictly “performance.” The rest are usability and correctness properties that also happen to only show up under load or under real conditions, which is exactly why they get skipped along with the performance ones.

CheckWhat breaks without itHow you verify it
PaginationThe page loads every row into memory to display twenty of themRead the query. Look for LIMIT and OFFSET, and a separate COUNT(*) for the total. No LIMIT means no pagination, full stop
IndexesThe query full-scans a table that grew past what anyone testedOpen the schema, find every column in WHERE, ORDER BY and JOIN, cross-check it against the table’s KEY definitions before the query ships
N+1 queriesA 50-row list quietly issues 51 queriesGrep for a query call inside a foreach. Either it’s replaced with a batch fetch (WP_User_Query with include, a find_many style call) or there’s a comment explaining why it’s acceptable
COUNT via COUNT(*)40,000 rows get loaded into PHP just to print the number 40,000Search for count() wrapping a fetch-all call. Replace it with a dedicated method that runs SELECT COUNT(*) and nothing else
Filter and sortA list of 2,000 items is technically present and practically unusableConfirm at least one filter (status, type, reason) and one sort (recency, age) exist and are wired to the query, not just the UI
Mobile and RTLThe table forces horizontal scroll on a phone, or flips wrong in ArabicCheck for token-driven spacing and margin-inline instead of margin-left/right. Load the page at 390px and in an RTL locale
Dark modeDark text sits on a dark surface and disappearsGrep component CSS for raw hex values. Every colour should trace back to a token
AccessibilityA screen reader announces an icon-only delete button as just “button”Check for semantic ul/table/nav rather than div soup, ARIA labels on icon-only controls, and tab through the page with no mouse
Empty, error, loading statesA blank rectangle that could mean loading, empty, or failed, and the user can’t tell whichTrigger all three states deliberately (throttle the network, force an empty result, force a 500) and confirm each one renders something distinct
Caching and invalidationA cached list keeps showing a row that was deleted five minutes agoConfirm there’s a cache key for the list and that every write path that touches it also clears or updates that key
Multi-actor concurrencyA second admin clicks approve on something already approved and gets a raw errorOpen the same list in two sessions, act on a row in one, then act on the same row in the other and check the response is graceful

That’s the whole list. It reads short. It is not short to actually run, and I want to go through the items in more depth because the one-line version hides where the real cost is.

The data layer: pagination, indexes, N+1, counting

These four live together because they’re all versions of the same question: how much work does the database do, and how much of that work is necessary. Pagination is the most obvious one and the easiest to get wrong in a subtle way.

It’s not enough to add LIMIT to a query. You need a real total from COUNT(*) so the UI can show “page 3 of 40” honestly, and you need actual prev/next controls, not just a query parameter nobody can reach. I’ve seen list views that technically paginate at the database level but have no way for a user to get past page one in the interface. That’s not pagination, that’s a hidden cap.

Indexes are the item people assume they’ve covered and haven’t. The habit that catches this reliably is boring but it works: before you add a WHERE, ORDER BY or JOIN clause, go open the table definition and look at the KEY list.

If the column you’re about to filter or sort on isn’t in there, you’re not “probably fine because it’s a small table.” You’re one growth season away from a slow query nobody flagged in review because it looked identical to a fast one.

N+1 queries are the sneakiest of the four because they’re invisible in every code review that doesn’t actually count queries. A loop that calls get_post_meta() or a custom lookup once per row reads completely normal in a diff. It’s fine at five rows. At 2,000 rows it’s 2,001 queries for a page that should have needed two.

The fix is almost always the same shape: pull the IDs first, batch-fetch with WP_User_Query and an include array, or a model method built to take an array of IDs and return them all in one query, then loop over data that’s already in memory. If batching genuinely isn’t feasible for some reason, that’s a legitimate call sometimes, but it needs a comment saying why, not silence.

Counting is the one I find people get wrong even after they’ve internalised the other three. It’s an easy mistake to make because count() is right there and it works. count(Model::list_all()) is correct in the sense that it returns the right number.

It’s also loading every row in the table into PHP just to throw away everything except the count. A dedicated count_*() method that runs SELECT COUNT(*) does the same job without the memory cost. The difference between the two only shows up once the table is big enough to matter, which is exactly when you’re least likely to notice you wrote it wrong.

The usability layer: filter, sort, mobile, dark mode, accessibility, states

This half of the list gets treated as polish. I’d argue it’s closer to correctness than decoration, because a feature that technically works but that nobody can actually use is not meaningfully different from a feature that’s broken.

A 2,000-row list with no filter and no sort is a list nobody can find anything in. It renders, the data is all there, and it is useless, which from a customer’s chair looks exactly like a bug.

Filter and sort don’t need to be elaborate. At minimum, surface the primary discriminator, whatever that is for the object type: status for a moderation queue, role for a member list, date for anything with a timeline. The mistake I see most is a filter that exists in the UI, styled and everything, and just isn’t wired to the actual query, so it silently does nothing past the first page.

Mobile and RTL sit together because they’re solved by the same discipline: token-driven CSS instead of hardcoded values. margin-inline-start instead of margin-left flips correctly for right-to-left languages without a second stylesheet.

Spacing tokens instead of raw pixel values make the row layout collapse cleanly under 480px instead of forcing horizontal scroll on a phone. Dark mode is the same idea applied to colour, and the single most common dark-mode bug, in my experience, is a hardcoded hex value sitting in component CSS that nobody thought to convert.

It’s invisible in light mode and it puts dark text on a dark card in dark mode, which is about as embarrassing a bug as a UI can have.

Accessibility is where I’ve had to correct my own instincts more than once. A styled div with a click handler looks identical to a real button in a screenshot. It is not identical to a keyboard user or a screen reader user, who gets an unlabeled, unfocusable nothing.

The fix is mechanical: semantic elements (ul, table, nav) instead of div soup, ARIA labels on any button that’s just an icon, and an honest keyboard-only pass through the page before calling it done.

Empty, error and loading states are the item most likely to get skipped under deadline pressure, because the “happy path with data” is the only state anyone tests by default. Every async surface has three failure-adjacent states in addition to the happy one.

If all three render as the same blank rectangle, the user has no way to tell “still loading,” “nothing here yet” and “this broke” apart. That ambiguity is worse than any single one of the three states on its own, because it makes the whole UI feel untrustworthy rather than just occasionally slow.

The systems layer: caching and concurrency

The last two items are the ones that only show up once a plugin has real, simultaneous, multi-person usage, which is exactly why they’re the easiest to defer and the most painful to retrofit.

Caching without a clear invalidation path is, in my opinion, worse than no caching at all. A slow uncached list is annoying. A cached list that keeps showing a row the user just deleted is actively lying to them, and once that happens they stop trusting the screen even after you fix it.

The rule we hold ourselves to is simple to state and easy to skip under time pressure: any shared list gets a cache key (a transient, or an object-cache entry via wp_cache_set), and every write path that touches that data clears or updates the same key. If you can’t point to the invalidation call, you don’t have a caching strategy, you have a bug waiting for someone to delete something.

Concurrency is the item people are most surprised to see on a checklist about list views, but it belongs here more than almost anything else on the list. Once you have more than one moderator, more than one admin, more than one person with the same permission looking at the same queue, “already resolved,” “already deleted” and “already taken” become states your UI has to handle, not edge cases you can wave off.

Two moderators open the same flag queue. One approves a post. The other’s browser still shows it as pending, they click approve too, and now the second request needs to fail gracefully, not throw a raw database error at someone who did nothing wrong except be a fraction of a second too slow.

This is exactly the kind of quiet, unglamorous hardening work we did across the BuddyNext moderation surfaces (BuddyNext 1.0.8 hardening release). It produces almost no visible feature and matters enormously the first time two real admins collide on the same queue.

What it actually looks like at different table sizes

I want to be careful here and not invent numbers I haven’t measured, because that’s its own kind of dishonesty and it undermines the actual point. I’m not going to tell you a query went from some invented millisecond figure to another invented millisecond figure.

What I can describe honestly is the qualitative experience, because that pattern repeats across almost every list view we’ve ever built or fixed.

Rows in tableExperience without the checklistWhat changes with it
5Instant. Every check on this list would pass by accident, which is exactly the problemNothing visibly changes. This is the size the bug hides at
500Still feels fine most of the time, with an occasional pause that’s easy to dismiss as a one-offThe pause disappears entirely and stays gone as the table keeps growing, because the query cost stopped scaling with row count
2,000A noticeable, repeatable delay on every load. Filtering or sorting either doesn’t exist or makes it worse. Support starts hearing “it feels slow”Load time stays flat. Filter and sort make the list usable instead of just present. This is the size our checklist is written for
50,000The page times out, or the request exhausts available memory before it finishes, or it “works” but takes long enough that the user assumes it’s broken and reloads, which makes it worseStill flat, still paginated, still usable, because nothing in the query path was ever proportional to total row count in the first place

The pattern in that table is the whole argument in one place. Without the checklist, the curve is flat for a long time and then falls off a cliff, and the cliff shows up long after the code shipped and long after whoever wrote it has moved on to something else.

With the checklist, there is no cliff, because none of the eleven items scale with row count in the first place. That’s the actual goal. Not “fast,” exactly. Flat.

Why this has to happen on day one

The obvious argument for building this in up front instead of retrofitting it is developer time, and that argument is true but it’s not the interesting part. Retrofitting a query to add pagination is genuinely not that hard in isolation. Adding an index is one line in a migration. The interesting part is what else is true by the time you notice you need to retrofit something.

By the time a list view is slow enough for someone to report it, real customer data is already sitting in that table. So the fix is no longer just a code change.

It’s a code change plus a data migration on a live site plus, usually, a UI change, because the interface was never designed with pagination or filtering in mind and now has to grow controls it never had. You’re doing three jobs at once, under time pressure, on production data you can’t casually break, instead of one job at design time on a schema you can still freely shape.

That gap in difficulty is not linear. It’s the difference between a design decision and an incident.

There’s a second cost that’s easy to underweight: trust. A feature that used to be instant and is now visibly struggling reads to a customer as regression, even though from our side it’s just success (more data, more usage) meeting code that was never built for it.

Explaining “it’s actually working as designed, it just doesn’t scale” to a customer whose site got slower is not a conversation anyone enjoys having, and it’s entirely avoidable.

This is part of why, when we build the developer-facing side of a plugin, we try to hold custom tables and REST surfaces to the same bar from the start rather than treating the API as an afterthought bolted onto an admin screen.

The REST and hooks work we did on Eventonomy (Eventonomy’s developer platform) and on Learnomy (Learnomy’s developer platform) both went through the same eleven items, because an endpoint that returns an unbounded array is exactly as broken as an admin list that does the same thing, just with a different consumer on the other end.

A third-party integration hitting an endpoint with 2,000 records behind it will hit the exact same cliff a human scrolling a table would, except it’ll hit it in a cron job at 3am with nobody watching.

How we actually run it

In practice this isn’t a document someone reads once. It’s a literal pass our team makes against every list, grid, table and data method before we call a feature done, and it applies whether the surface is:

  • An admin table showing moderation queues, orders, or member lists
  • A frontend archive or directory a logged-in member scrolls through
  • A REST endpoint that returns a collection to a mobile app or a third-party integration
  • An export or report that has to walk the whole dataset rather than a page of it

For each of those, the honest version of the process looks like this:

  1. Read the query and check it against the pagination, index, N+1 and counting items before writing UI around it
  2. Check the primary filter and sort actually exist and are wired through to the query, not just present in the markup
  3. Verify the responsive, dark mode and accessibility items with a real pass at 390px, in dark mode, and with a keyboard
  4. Force all three async states (loading, empty, error) deliberately rather than trusting the happy path
  5. Confirm the cache key and its invalidation path if the data is shared across requests
  6. Open the same view in two sessions and act on the same row from both, to catch the concurrency gap before a real customer does

None of that is exotic. It’s mostly just refusing to let “looks fine on five rows” count as proof, and being willing to seed a large dataset with WP-CLI when we’re not sure a view is covered rather than assuming it is.

That last habit is the one I’ve had to enforce most, honestly, because it’s tempting to eyeball a screen and move on. Eyeballing a screen with five rows tells you almost nothing about the screen anyone’s actual customer will see.

Zoom out far enough and this checklist is really just one instance of a bigger habit we try to hold across the whole portfolio: build the thing so it still works when it’s actually being used, not just when it’s being demoed.

That’s the same instinct behind how we’ve tried to structure the broader stack across our products (the complete WordPress platform stack), where the boring, unglamorous plumbing decisions made early are the reason later features don’t have to fight the foundation.

A list view is a small, specific place to apply that instinct. It’s also one of the most common places in any plugin, which is exactly why it earned its own checklist instead of staying a vague intention.