Pagination is one of those things that looks finished the moment your list renders. You fetch some
rows, you show some rows, you move on. Then six months pass, a customer onboards their entire org,
and suddenly that innocent SELECT * is dragging forty thousand records across the wire so the
browser can render the ten you can actually see. The feature didn't break. The assumption did.
Running a software company and shipping several apps across different clients, we see this exact problem repeatedly, usually wearing a different costume each time. So this isn't a post about cursor-vs-offset or keyset strategies. Pick whatever fits your data; that's your call. This is about the part people skip: pagination is a property of the whole system, and designing for it takes more thought than it gets credit for.
Scale arrives faster than you think, especially internally
There's a comforting lie we tell ourselves: "every query is scoped to an organization, so each
tenant's data stays small." Sometimes true. For customer-facing screens, your organizationId index
is doing a lot of quiet load-bearing work.
But then you build the internal dashboards, the ops console, the platform-wide view of every client, every assignment, every uploaded document. None of that is scoped to a single tenant. It's scoped to all of them, at once. That data doesn't grow linearly with one customer; it grows with the entire business. The internal "admin list nobody will ever scroll" is usually the first thing to fall over.
So "it's just an internal tool" is not a reason to skip pagination. It's a reason to take it more seriously.
It's not just lists
Here's the part that catches teams off guard. When people hear "pagination," they picture a table with a "Load more" button. But the same scaling problem shows up everywhere you render a set of things you didn't bound:
- The assignee picker in a "New task" form.
- The "filter by assignee" dropdown in a table toolbar.
- The multi-select that lets you narrow results by owner.
A <select> with every user in it is a paginated list wearing a disguise. The fix is the same idea
as a paginated table: don't ship everything, ask the server for what matches as the user types.
Here's a task's "assignee" field doing exactly that:
function AssigneeField({ organizationId }: { organizationId: string }) {
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, { wait: 500 });
const { data: assignees, isPending } = useQuery(
api.task.search.searchAssignees,
{ identifier: organizationId, search: debouncedSearch },
);
const options = optionsFrom(
[assignees],
(a) => a._id,
(a) => a.name,
);
return (
<form.AppField name="assignee">
{(field) => (
<field.SingleSelect
label="Assignee"
placeholder="Select assignee"
options={options}
search={search}
onSearch={setSearch}
searching={isPending}
/>
)}
</form.AppField>
);
}That dropdown never tries to hold every user at once. It debounces what you type, asks the server, and shows what comes back.
Search isn't a separate system, it is the pagination
This is the mental shift that makes everything else click. The instant your dataset is too big to ship to the client, "search" stops being a nice-to-have bolted on top of your list and becomes the primary way anyone navigates it. You cannot page through ten thousand rows by clicking "next" forty times. You search.
Which means search, filters and pagination have to be the same pipeline, not three features that happen to share a screen. A good search endpoint folds all of those concerns into one place: a search term, a way to fetch specific rows by id and a hard cap on how much it will ever return.
async function searchAssignees({ search, ids, take = 50 }) {
const limit = Math.min(take, 250);
return db.users
.filter((user) => {
if (search && !user.name.toLowerCase().includes(search.toLowerCase())) {
return false;
}
if (ids?.length && !ids.includes(user.id)) {
return false;
}
return true;
})
.map((user) => ({ id: user.id, name: user.name }))
.take(limit);
}Notice that ids argument sitting right next to search. That's not an accident, and it's the
setup for the nastiest little bug in paginated UIs.
The refresh problem (a tiny detail that quietly ruins things)
Picture this. A user filters a table by assignee, picks "Jane Doe," and the filter writes
assigneeId=<jane_id> into the URL. Good. Filters belong in the URL so they survive refreshes and
can be shared. Then they reload the page.
Now the table toolbar wants to render the active filter badge. It knows the id. It does not know the label, because "Jane Doe" might be the 8,000th user alphabetically and isn't anywhere near the first page of search results. So your filter chip renders a raw ID, or worse, an empty box. The filter works, but it looks broken, and the checkmark in the dropdown is gone too.
The fix is to fetch the selected ids by id and merge them into whatever search returned. That's the
dual-query pattern, and it's exactly why the search endpoint above accepts ids:
function useAssigneeFilter() {
const [query, setQuery] = useQueryStates(parsers);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebouncedValue(search, { wait: 500 });
// What matches what you're typing.
const { data: searchVals, isPending } = useQuery(api.assignees.search, {
search: debouncedSearch,
take: 50,
});
// The things you already selected, fetched by id.
const { data: selectedVals } = useQuery(
api.assignees.search,
query.assigneeId ? { ids: query.assigneeId } : "skip",
);
return multiSelectFilter({
title: "Assignee",
values: query.assigneeId,
onValuesChange: (assigneeId) => setQuery({ assigneeId }),
onClear: () => setQuery({ assigneeId: null }),
options: optionsFrom(
[searchVals, selectedVals],
(a) => a.id,
(a) => a.name,
),
search,
onSearch: setSearch,
searching: isPending,
});
}Two queries: one for "what matches what you're typing," one for "the things you already selected."
A small helper (optionsFrom here) flattens and de-duplicates both lists so your selections never
get evicted mid-search. It's a small amount of code for a problem most teams don't discover until a
user files a confused bug report.
A little caching goes a long way for UX
Server-side search has one annoying side effect: options blink. You type a letter, the query re-fetches, the data is briefly empty, and an option that was on screen a moment ago vanishes and reappears. Technically correct. Visually awful.
The trick is to remember options you've already seen and keep stale entries around until the context
changes. We wrap our option lists in a small useCachedValues hook that does exactly that: it
merges the current results with everything seen before, so the dropdown stays stable while a new
query is in flight. The same instinct applies to the list itself, holding onto the last good page
while the next one loads so the table doesn't flash empty every time someone loads more.
None of these are dramatic. Caching, stable lists, merging by id. They're the unglamorous details that separate "works in the demo" from "feels good at 9am on a Monday with real data."
When not to paginate
Now the plot twist: sometimes pagination is the wrong tool, and reaching for it adds latency, complexity and round-trips for no benefit.
If you're serving a static, bounded list, like country codes, service types, a configuration enum, or a few thousand rows of reference data, just send it. Render it client-side and let the table filter and page in the browser. People wildly underestimate how much data the browser can chew through. The TanStack Table docs put it plainly: their official pagination example loads 100,000 rows and still performs well. Your "huge" lookup table is probably not huge.
The questions worth asking before you paginate: can the server query it all cheaply, is the payload reasonable and will the browser choke on it in memory? If the answer is "fine, fine, fine," skip the machinery. You can always add it later, which brings me to the point I actually care about.
Design for it, even if you don't need it yet
Here's the thing about all the code above: not every list we build is paginated on day one. Some screens happily render their full dataset because, right now, the dataset is small. That's a deliberate trade-off, not an oversight.
The difference is that the seams are already in place. Search, filtering, fetch-by-id, option caching, stable lists. They're built as composable pieces, so when a list does outgrow its assumptions, flipping it to paginated is a contained change, not a rewrite. Pagination is hard to retrofit precisely because it touches the URL, the filters, the dropdowns, the search and the loading states all at once. If those concerns are tangled together, you're rebuilding all of them under deadline pressure. Kept separate, you swap one query for another and move on.
That's really the whole argument. Pagination isn't a button you add when the list gets long. It's a way of thinking about data, where it lives, how it grows and how people find the one row they actually wanted, baked in early enough that scale becomes a non-event instead of an incident.
The lists will get long. The only question is whether your system was expecting them to.