An admin panel always starts the same way: a table, four test rows and a select with no where. It works. And because it works, nobody looks at it again. Meanwhile the users table goes from four rows to forty thousand, and the internal page nobody measures becomes the slowest one in the product.
I've had to fix this in four different projects, and always for the same three reasons.
The admin gets written carelessly because nobody sees it
The public page has pagination from day one. Someone reviews it, someone measures it, there's a Core Web Vitals metric screaming if it's slow. The admin has none of that. Two people use it, and those two people assume it's slow because "it's the admin".
In a transcription API I maintain, the server actions getAdminUsers and getAdminFeedback took limit as a parameter and passed it straight into the query. No ceiling. Nobody had touched it in months because the UI always sent a 10. The problem isn't the UI: it's that a server action is an endpoint. Anyone with an admin session, or any script, could ask for limit=1000000 and materialize the whole table in the worker's memory.
The fix was three lines, in a security pass:
export const ADMIN_LIST_MAX_LIMIT = 1000;
const cappedLimit = Math.min(Math.max(limit, 1), ADMIN_LIST_MAX_LIMIT);
The Math.max(limit, 1) isn't free-floating paranoia: a negative or zero limit also does strange things depending on the driver. What matters is where the ceiling lives. Not in the component, not in the hook: in the layer that receives the request. Everything above that is a suggestion.
The count that looks free and isn't
Classic pagination needs to know how many pages there are, and that means a SELECT COUNT(*). You write it without thinking because in your head a count is cheap. It isn't: in Postgres an exact count walks the whole table or the whole index, and there's no way around it.
In an internal document manager, the user admin did three things in parallel on every page load: fetch the page of profiles, fetch every company for the selector, and count the total profiles. With a pageSize of 5. In other words, it scanned the entire table to render "page 3 of 47".
I dropped the count and changed the pagination contract:
setHasMore(profilesData.length === pageSize);
You lose the "of 47". You gain a query that's always proportional to the page size. The next button now depends on hasMore instead of currentPage < totalPages, and the displayed range is computed from what you already have in hand, without asking for anything else:
const endIndex = startIndex + companies.length - 1;
Nobody complained about not seeing the total. In an internal panel it's a number that decides absolutely nothing. If you really need it, reltuples from pg_class gives you an estimate at zero cost; for "there are about 40k users" that's plenty.
Fetching metadata instead of the whole object
The third pattern is subtler. A file list loads fine until the files have content. In a collaborative notes tool the sidebar called getProjectTextFiles, which did a select with no projection: it also pulled the content column of every file in the project. To render a list of names.
The fix is boring and it works: a second function that explicitly selects the columns the list needs.
return await db
.select({
id: textFiles.id,
projectId: textFiles.projectId,
name: textFiles.name,
isTemp: textFiles.isTemp,
deletedAt: textFiles.deletedAt,
createdAt: textFiles.createdAt,
updatedAt: textFiles.updatedAt,
})
.from(textFiles)
.where(and(eq(textFiles.projectId, projectId), isNull(textFiles.deletedAt)));
Content is requested when you select a file, not before. And that's where the detail you only see once you've done it shows up: if the user clicks two files quickly, the slow response from the first can stomp on the second. Before writing to the store you have to check that the file is still the selected one:
if (fullFile && useAppStore.getState().selectedFileId === fileId) {
useAppStore.getState().setSelectedFile(fullFile);
}
Without that check you trade a performance problem for a race condition, which is worse because you can't reproduce it on demand.
The bug that only shows up in the admin panel
One of the best examples of why the admin deserves attention came from an analytics dashboard I cached with unstable_cache. It worked locally and on the first production visit. After that it died with Invalid time value.
unstable_cache serializes its payload to JSON. A Date written on the cold run comes back as a string on every cached read. And Intl.DateTimeFormat.format doesn't take a string. The type was lying: lastIngestedAt: Date | null was really Date | string | null. TypeScript couldn't see it because the serialization boundary isn't typed.
I fixed it by normalizing both shapes and treating the unrenderable as absent, not as an exception:
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : formatter.format(date);
A date you can't render should show as "no data", not take the whole page down. In a dashboard, one broken metric can't take the other twelve with it.
What I apply today to any internal list
- A ceiling in the server layer. A
limitarriving from outside gets clamped where it enters, with a named constant. The UI is not validation. - No
countby default. Pagination byhasMore. If someone asks for the total, give them an estimate, and only if they ask. - Explicit projection. A list selects columns, never
*. The blob is fetched when you open the detail. - An index on the column you filter by. In a time-tracking app there were composite indexes on
(user_id, date), but queries filtering only byuser_iddidn't use them well; we had to add the single-column index.
The admin panel is where the team looks when something goes wrong in production. That it's exactly the part of the system that can't handle production volume is an expensive irony.
