Design a pagination control
The numbered page control under search results, tables and listings. Drawing the buttons is easy; the real work is the page window algorithm that never jumps in width, keeping the page in the URL as real links, living with totals that are expensive or unknown, fetching pages without flashes or races, a compact mobile mode, and a control that screen reader and keyboard users can follow.
The question
Design the pagination control for our design system. It sits under product search results, under admin tables, and under the order history page. Some lists have 40 items, some have 3 million.
Walk me through which page numbers it shows, where the current page is stored, what you do when the backend can't tell you the total, how a page change loads, and how it works on a phone and with a screen reader.
Real-life examples: Google search result pages, GitHub issue and pull request lists, Amazon search results, MUI Pagination and its usePagination hook, Ant Design Pagination with its page size changer and quick jumper
Requirements: what to ask before drawing buttons
A pagination control is the row of links under a long list that splits it into pages: Previous, a few page numbers, and Next. You click 4 and you see items 61 to 80. Google search, GitHub issues and Amazon results all end with one.
It looks like a loop from 1 to the number of pages. That breaks the moment a list has 400 pages. A senior answer treats the control as the visible end of a bigger system: the URL, the API, the total count, the cache and the focus all move when the page changes.
Start with questions. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Where is it used? | Search results, admin tables, order history. | The control can't own the data. It gets the page and total from outside. |
| How big are the lists? | 40 items to 3 million rows. | We can't render every number, and counting 3 million rows per request is slow. |
| Does the backend know the total? | Search estimates. Orders can count, in 1 to 2 seconds. One feed is cursor based. | Modes for exact, approximate, capped and unknown totals. |
| Shareable and indexed? | Yes. Pages are crawled and links to page 3 get pasted in chat. | The page lives in the URL, and every number is a real link. |
| Page size choice? | In admin tables (10, 25, 50, 100). | Where the size is stored, and where you land after changing it. |
| Devices and languages? | 60 percent phones at 360 pixels. Arabic and Hebrew too. | A compact mode, and arrows that mirror right to left. |
Functional requirements
- Previous, Next, first and last page, the current page and its neighbors, with ellipses for gaps.
- The page and page size live in the URL, so reload, Back, share and crawl all work.
- Works with exact, approximate ("about 1,200"), capped ("1,000+") and unknown totals.
- Jump to a page by number; pick a page size in tables.
- On a change: keep the old page visible while loading, move to the top of the list, never show a stale page.
- A compact phone layout: Previous, "Page 3 of 12", Next.
Non-functional requirements
- Fast: a cached page change paints in under 100 ms; an uncached one shows data in about 500 ms on 4G.
- Stable: the control never changes width while you page, so Next doesn't move under your finger.
- Accessible: WCAG 2.2 AA, targets of at least 24 by 24 CSS pixels.
- Small and crawlable: under 3 KB gzipped, every page reachable by a plain link in the server HTML.
Out of scope
- Infinite feeds: see the infinite scroll guide. We only compare the patterns.
- Sorting, filtering and the table itself (the data table guide). Here they only reset the page.
- Writing the database queries: the pagination lesson covers offset and cursor queries.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the control itself, not just its styling? Select all that apply.
What makes it hard
Four facts drive every later decision; each gets its own chapter.
- The list is bigger than the control. 12,400 results at 20 per page is 620 pages, and the control has room for about 9 items. It must pick which numbers to show, at the same width on page 1 and page 620.
- The page is shared state. The router, the fetcher, the server renderer, crawlers and the person you send the link to all read it. In a
useStateinside the control, all of them lose it. - The total is often a guess or a luxury. Counting every match can cost more than fetching the page, search engines only estimate, and cursor APIs don't know it at all.
- A page change replaces the whole list. Loading states, scroll position, focus and out-of-order responses all show up at once.
Here is the control we're building, in its two layouts.
Wide screens get Previous, the page window (numbers and ellipses), Next, and a "Go to page" field, with a summary above the list. Phones get Previous, "Page 3 of 620" (which opens the jump field), and Next.
Numbered pages, Load more, or infinite scroll
First check that numbered pages are the right pattern. Interviewers often ask "why not infinite scroll?" and want a reason.
A buyer sees a pair of shoes "on page 3, near the bottom", opens a few tabs, and comes back an hour later. With numbered pages, page 3 is a link and the item is still roughly there. With an infinite feed she scrolls from the top again.
Decision How should a long list be split for the reader?
What we pick: Numbered pages when people search, compare and come back: search results, admin tables, order history. Infinite scroll for feeds people browse with no goal (see the infinite scroll guide). Load more sits in between. Our three lists are all of the first kind.
Real products follow this split. Google search tried continuous scrolling and dropped it in June 2024: desktop went back to numbered pages, and phones got a "More results" button. GitHub, Amazon and nearly every admin tool use numbered pages; Instagram and TikTok use infinite feeds.
Numbered pages also depend on the API. Jumping to page 40 needs an offset ("skip 780 rows, take 20"). A cursor API ("20 after this id") can only step forward and back. The pagination lesson explains both; the total count chapter shows the control for each.
Check your understanding · Exercise 2
For which lists would you choose numbered pages over infinite scroll? Select all that apply.
The page window: which numbers to show
The page window is the items between Previous and Next: page numbers and ellipses. The naive version renders range(1, totalPages): with 620 pages, a row that wraps across six lines.
The second naive version shows 5 numbers around the current page. Now there's no one-click way back to page 1, no hint how many pages exist, and the row changes length near the ends.
Decision Which items should the window show on a long list?
What we pick: First, last, siblings and ellipses, with siblingCount and boundaryCount props defaulting to 1, as in MUI's usePagination. Ellipses stay plain text; long lists get a jump field.
The three rules
- Always show the ends. One click to page 1 or page 620.
- Never hide just one page. In "1 … 3" the ellipsis takes the space of the 2 it hides. Use an ellipsis only for 2 or more pages.
- Keep the item count stable. With the defaults, always 7 items once there are more than 7 pages. Near the start the block grows right: page 1 shows
1 2 3 4 5 … 620, not1 2 … 620.
type Item = number | "gap-start" | "gap-end";
const range = (a: number, b: number) =>
b < a ? [] : Array.from({ length: b - a + 1 }, (_, i) => a + i);
export function pageWindow(page: number, total: number, siblings = 1, boundary = 1): Item[] {
const slots = 2 * boundary + 2 * siblings + 3; // ends + block + two gaps
if (total <= slots) return range(1, total);
// The sibling block, pushed away from the ends so the count stays fixed.
const start = Math.max(Math.min(page - siblings, total - boundary - 2 * siblings - 1), boundary + 2);
const end = Math.min(Math.max(page + siblings, boundary + 2 * siblings + 2), total - boundary - 1);
return [
...range(1, boundary),
start > boundary + 2 ? "gap-start" : boundary + 1, // a gap of 1 is shown as the page
...range(start, end),
end < total - boundary - 1 ? "gap-end" : total - boundary,
...range(total - boundary + 1, total),
];
}
Walk through 10 pages with the defaults (7 slots). The 3-page sibling block is clamped between pages 3 and 8, leaving room for each end and its neighbor slot.
| Current page | Items | What to notice |
|---|---|---|
| 1 to 3 | 1 2 3 4 5 … 10 | The block is pushed right to fill 7 slots. Only the marker moves. |
| 4 | 1 2 3 4 5 … 10 | A gap would hide only page 2, so 2 is shown, not "1 … 3". |
| 5 and 6 | 1 … 4 5 6 … 10, 1 … 5 6 7 … 10 | Both gaps appear, each hiding 2 or more pages. |
| 7 to 10 | 1 … 6 7 8 9 10 | The mirror of pages 1 to 4. On 10, Next is disabled. |
| total 6 | 1 2 3 4 5 6 | Fewer pages than slots: show all. |
| total 1 | nothing | Hide the control; keep the result summary. |
Try it. The count changes only with the total, never while you page, and no ellipsis hides a single page.
A stable count isn't the whole story: "9" is narrower than "620". Give every item the same min-width (40 pixels), use font-variant-numeric: tabular-nums, and always render Previous and Next, disabled at the ends. Now a user can click Next 5 times without moving the mouse.
The two counts are a trade-off between reach and width. siblingCount 2 shows 9 items and lets users step 2 pages either way with one click; it suits wide admin tables. boundaryCount 0 drops the ends and fits phones, but then page 1 is only reachable through Previous or the jump field. Defaults of 1 and 1 are the safe middle.
Think first
Why is pageWindow a pure function outside the component, and not code inside the render?
It holds all the edge cases, and a pure function is trivial to test with a table of inputs and outputs: totals of 0, 1, 7 and 8, and the pages next to each end.
The server, the compact layout and a headless hook can share it without rendering.
Check your understanding · Exercise 3
With 50 pages, siblingCount 1 and boundaryCount 1, which rows are correct? Select all that apply.
The page lives in the URL, and every page is a link
The naive control keeps useState(1) and renders button elements. Then a support agent sends a link to page 7 and the colleague lands on page 1. Reload loses the page, Back leaves the list, and crawlers never find page 2, because they don't click buttons.
Decision Where does the current page live, and what does clicking a number do?
What we pick: The URL is the source of truth and every page is a real link. The control reads page from props and never stores its own copy: the url-as-state lesson, applied to one number.
What happens on a click
href in a new tab.A plain click is caught by the router's Link, which calls pushState. Cmd click, middle click and "open in new tab" skip the handler and use the href, so it must always be correct.
Each page click pushes a history entry, so Back returns to the previous page, the same as plain links on any website. A clamping redirect uses replace instead of push, so Back never lands on the invalid URL. The jump field and page size changes push, because users see them as navigation.
export function pageHref(pathname: string, params: URLSearchParams, page: number) {
const next = new URLSearchParams(params);
if (page <= 1) next.delete("page");
else next.set("page", String(page));
const qs = next.toString();
return qs ? `${pathname}?${qs}` : pathname;
}
// pageHref("/search", new URLSearchParams("q=shoes&sort=price&page=2"), 3)
// -> "/search?q=shoes&sort=price&page=3"
Search engines: canonical URLs, not rel=prev/next
- Each page is its own canonical. Page 3's
link rel="canonical"points to?page=3. Pointing every page at page 1 marks the others as copies, so their items may never be indexed. - Google no longer uses
rel="prev"andrel="next". It said so in 2019. They're harmless and other engines may read them, but crawlablea hreflinks are what matter. - One URL per page, in the query string.
pageHrefdropspage=1. Crawlers ignore everything after#, so#page=3is invisible. - Keep sorted and filtered copies out of the index, as Google suggests, with a
noindexrobots meta tag or a robots.txt rule:?sort=price&page=3is the same items in another order.
Invalid pages: parse, clamp, redirect
The URL is user input: ?page=abc, ?page=-2, or an old link to ?page=40 after the list shrank to 12 pages. Parse strictly on the server, before rendering.
| URL | What we do | Why |
|---|---|---|
?page=abc, 0, -2, 2.5 | Redirect to the bare URL (page 1). | Garbage shouldn't create indexable URLs. |
?page=40 of 12 | Temporary redirect (302 or 307) to ?page=12. | The list may grow back, so not 301. The last real page beats an empty one. |
| A filter changes on page 4 | The new URL drops page. | Page 4 of the old result means nothing now. |
Check your understanding · Exercise 4
Which choices keep pagination shareable and crawlable? Select all that apply.
The total count problem
The window needs totalPages, so the naive API runs two queries per request: SELECT ... LIMIT 20 OFFSET 40 for the page and SELECT COUNT(*) ... WHERE status = 'shipped' for the total.
On the 3 million row orders table the page query uses an index and takes about 5 ms. The count is different. PostgreSQL keeps no stored count per filter and must check that each matching row is visible to the transaction, so it visits every one. With 2 million matches that is the 1 to 2 seconds from our requirements, on every click.
Search has the same problem at scale. Google labels its counts "About" because they are estimates. GitHub's search API serves only the first 1,000 results of any search. Elasticsearch counts exactly only up to 10,000 hits by default (its track_total_hits setting), then reports 10,000 as a lower bound.
Decision What total does the API return, and what does the control show?
What we pick: The API says what it knows and the control adapts: a total of kind exact, estimate or atLeast, or unknown with only a hasNext flag. Orders: exact for narrow filters, capped at 10,000 otherwise. Search: the engine's estimate. The cursor feed: Previous and Next.
Compare the four on the same list: what the control can show, and what the server pays.
When the API is cursor based
A cursor is an opaque token pointing at a row. "20 rows after this one" is fast at any depth and doesn't skip rows on inserts, but there is no page 40 without walking 39 pages. The reddit guide goes deeper into cursor listings.
- The URL holds the cursor,
?after=eyJpZCI6ODgyMTN9: shareable, but not "page 5". - The control shows Previous and Next, maybe a client-counted "Page 5" (hidden on shared links).
- Previous needs a
beforecursor from the API, so it works on a shared link. - A "First page" link is cheap. "Last page" needs a reverse sort.
export type PageTotal =
| { kind: "exact"; totalItems: number } // "Page 3 of 117", last page link
| { kind: "estimate"; totalItems: number } // "About 12,400 results"
| { kind: "atLeast"; totalItems: number } // "10,000+ results", no last link
| { kind: "unknown" }; // Previous and Next only, from hasNext
export function lastPage(total: PageTotal, pageSize: number): number | null {
if (total.kind === "exact" || total.kind === "estimate")
return Math.max(1, Math.ceil(total.totalItems / pageSize));
return null; // atLeast and unknown: the window has no right end
}
When lastPage is null, the window drops the last page and the end ellipsis means "more": 1 … 6 7 8 … Next, with "10,000+ results, refine your search".
Check your understanding · Exercise 5
The admin orders table has 3 million rows and an exact COUNT(*) takes 1.5 seconds with the widest filter. What helps? Select all that apply.
Changing pages: loading, scroll, focus and races
The naive page change clears the list, shows a spinner, fetches and renders. Four things go wrong:
- The list collapses and the footer jumps (a layout shift).
- The user is still scrolled to the bottom, far from the new rows.
- Focus was on a link that may be gone, so keyboard users restart from the top.
- Fast clicks can show page 3's rows under a control that says 4.
Keep the old page until the new one arrives
Decision What does the list show while the next page loads?
What we pick: Skeletons for the first load, the previous page after that. The control marks page 4 current at once (the URL says 4), and a thin progress bar appears if the wait passes about 300 ms. See the server-state-libraries lesson.
const queryClient = useQueryClient();
const { data, isPlaceholderData, isError, refetch } = useQuery({
queryKey: ["orders", { status, sort, page, pageSize }],
queryFn: ({ signal }) => fetchOrders({ status, sort, page, pageSize }, signal),
placeholderData: keepPreviousData, // show page 3 while page 4 loads
staleTime: 30_000,
});
// Prefetch the next page once this one has arrived (not while page 3 stands in for it).
useEffect(() => {
if (isPlaceholderData || !data?.hasNext) return;
queryClient.prefetchQuery({
queryKey: ["orders", { status, sort, page: page + 1, pageSize }],
queryFn: ({ signal }) => fetchOrders({ status, sort, page: page + 1, pageSize }, signal),
});
}, [data, isPlaceholderData, page, pageSize, sort, status, queryClient]);
Prefetching makes most changes instant. Most users go next, so fetch page N+1 once page N renders. For other numbers, prefetch on pointerenter or focus: a hover usually comes a few hundred milliseconds before the click. Don't prefetch every visible number: 7 extra requests per view.
The page request as a state machine
Fast clicks and out-of-order responses
Three fast clicks request pages 4, 5 and 6. If page 5 arrives last, a naive setRows(response) shows it under a control that says 6. The fix, from the request-races lesson: only the response for the current key renders. A cache keyed by page does this for you.
signal to fetch also lets the cache abort the request nobody is waiting for.Passing the query's signal to fetch also lets the cache abort requests nobody waits for, which saves server work when someone clicks through 6 pages to reach page 10. Aborting is an optimization, not the fix: abort does nothing once a body has been fully read, so the key check is still what keeps the screen correct.
Scroll and focus after the change
- Scroll to the list, not the page top.
listHeading.scrollIntoView({ block: "start" }), withscroll-margin-topfor any sticky header. Skip it if the list top is visible. - Move focus to the list heading (
tabindex="-1") withfocus({ preventScroll: true }). Screen readers read "Orders, page 4", and Tab goes into the new rows (see the focus-management lesson). - One rule everywhere. Some teams keep focus on Next so users can skim with Enter. Either works; mixing them doesn't.
- Respect reduced motion: smooth scrolling only without
prefers-reduced-motion: reduce. - Update
document.titleto "Orders, page 4 of 117" so history entries differ.
Check your understanding · Exercise 6
A user on page 3 clicks Next twice quickly. Which behaviors are correct? Select all that apply.
Page size and jumping to a page
Changing the page size
Admin tables offer 10, 25, 50 and 100 rows. Where is the choice stored, and where do you land after changing it?
Store it in the URL (?size=50) so links show the same rows, and in localStorage per table for the next visit. The URL wins. Accept only the offered values, on client and server, so nobody requests ?size=100000.
Decision A user on page 7 at 25 per page (rows 151 to 175) switches to 100 per page. Where do they land?
What we pick: Keep the first visible item when the size changes: users want more around what they're reading. Reset to page 1 when a filter, sort or search changes, because the old position means nothing.
Jumping to a page
With 117 pages, reaching page 63 takes about 30 clicks. Add a "Go to page" form past about 10 pages (Ant Design's quick jumper).
- A real
formwith a labelledinput type="text" inputmode="numeric": a number keyboard on phones, without the number input's scroll wheel quirks. - Submit navigates to the page link, through the same URL and clamping path as a click.
- Validate digits from 1 to the last page; show "Enter a page from 1 to 117" via
aria-describedbyand keep focus in the field. - With capped or unknown totals, limit or hide it.
Check your understanding · Exercise 7
A user is on page 9 at 10 per page (rows 81 to 90) and picks 50 per page. Using "keep the first visible item", which page should open, and what else is right? Select all that apply.
Architecture, data model and component API
The rule that shapes everything: the control is a pure view of the URL and the total. It doesn't fetch, store the page, or know which list it's under, so one component serves every list.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Router and URL | Holds and validates page, size, filters, sort. | One truth for reload, share, Back and the server. |
| Server loader | Fetches the first page, clamps or redirects, renders links. | Crawlers and first paint get real content. |
| List query hook | URL params to a query key; keeps previous, prefetches next. | Caching and races handled once for every list. |
usePagination | Pure: page and total in, items out. | Headless, so custom markup reuses the rules. |
Pagination | The nav, links, current marker, compact layout. | Styling and accessibility in one place. |
| List view | Rows, busy state, the heading that takes focus. | It knows where the list starts, so it owns scroll and focus. |
How data flows
GET with the page in the query string. The only thing the control writes is the URL.Read path: the URL gives page=4, the hook calls GET /api/orders?status=shipped&page=4&size=25, and the list and control render from the rows and total. Write path: a click changes the URL, nothing else.
Data model and API
// URL (source of truth): /orders?status=shipped&sort=-date&page=4&size=25
type ListParams = { status?: string; sort: string; page: number; size: 10 | 25 | 50 | 100 };
// Server cache, keyed by ListParams: one entry per page.
type PageResponse<T> = {
items: T[];
page: number; // the page the server actually served, after clamping
size: number;
total: PageTotal; // exact, estimate, atLeast, or unknown
hasNext: boolean; // the server fetched size + 1 rows
nextCursor?: string; // only on cursor endpoints
};
// localStorage: the user's preferred size per table, e.g. "orders.pageSize" = "50".
The response echoes page: if the client asked for 40 and got 12, it replaces the URL with ?page=12.
The component API
Decision How does the control tell the app that the page changed?
What we pick: Ship all three: getHref by default, onChange for in-memory lists, the headless hook for rare custom designs. It's the controlled pattern from the component-api-design lesson.
type PaginationProps = {
page: number; // controlled; from the URL
total: PageTotal; // see chapter 6
pageSize: number;
getHref?: (page: number) => string; // links (default)
onChange?: (page: number) => void; // buttons, for in-memory lists
siblingCount?: number; // default 1
boundaryCount?: number; // default 1
linkComponent?: React.ElementType; // the router's Link
renderItem?: (item: PaginationItem) => React.ReactNode;
"aria-label"?: string; // default "Pagination"
};
Notice what's missing: no internal page state, no fetch function, no useEffect. The component renders the same HTML on the server and the client, so it hydrates without a mismatch, and it works with JavaScript disabled because every item is a link.
Check your understanding · Exercise 8
Which statements about this architecture are true? Select all that apply.
Mobile, accessibility and right-to-left
Compact mode on phones
The full row needs about 450 pixels; a 360 pixel phone has about 328 inside its margins. Shrinking links makes them hard to tap, and wrapping puts page 7 under page 2.
- Compact layout: Previous, "Page 3 of 117", Next, with 44 pixel arrows. The label opens the jump field, or a native
selectunder about 50 pages. - Switch with a container query, since the same table may sit in a narrow side panel.
@container (max-width: 480px)swaps layouts; both are in the server HTML, so there's no flash. - Or drop siblings first:
siblingCount0 gives1 … 5 … 117, 5 items.
Accessibility
<nav aria-label="Pagination">
<ul>
<li><a href="/orders?page=2" aria-label="Previous page" rel="prev">Previous</a></li>
<li><a href="/orders" aria-label="Page 1">1</a></li>
<li><a href="/orders?page=2" aria-label="Page 2">2</a></li>
<li><a href="/orders?page=3" aria-label="Page 3" aria-current="page">3</a></li>
<li><a href="/orders?page=4" aria-label="Page 4">4</a></li>
<li><a href="/orders?page=5" aria-label="Page 5">5</a></li>
<li aria-hidden="true">…</li>
<li><a href="/orders?page=117" aria-label="Page 117, last page">117</a></li>
<li><a href="/orders?page=4" aria-label="Next page" rel="next">Next</a></li>
</ul>
</nav>
- A named landmark.
navwitharia-label="Pagination"appears in the landmarks list. Two on a page need different labels. aria-current="page"on the current link, plus a style beyond color. Screen readers announce it as the current page (see the aria-patterns lesson).- Names that stand alone. "4, link" means little in a links list;
aria-label="Page 4"fixes it and still contains the visible text. - Disabled, not hidden, at the ends. On page 1, Previous stays in place as an
awith nohref,role="link"andaria-disabled="true": not focusable, still announced as unavailable. - Ellipses are decoration:
aria-hidden="true". - Target size. WCAG 2.2 criterion 2.5.8 (AA) asks for at least 24 by 24 CSS pixels or enough spacing. We use 40, and 44 on touch.
- Keyboard. Links are Tab stops opened with Enter. No arrow keys: this is a set of links, not a composite widget (see the keyboard-navigation lesson).
Designers often want the control above and below a long table. Two landmarks named "Pagination" confuse screen reader users, so label them "Pagination, top" and "Pagination, bottom", or show only the result summary on top. Both render from the same URL, so they can never disagree.
Right-to-left languages
In Arabic and Hebrew page 1 is on the right and Next points left. A flex row with logical properties (padding-inline) reverses on its own, but arrow icons don't: mirror them with transform: scaleX(-1) under :dir(rtl). Format totals with Intl.NumberFormat(locale) ("12.400" in German). The rtl-layouts lesson covers which icons to mirror.
Check your understanding · Exercise 9
Which markup choices are right for the control? Select all that apply.
Running it in the interview
This question usually gets 30 minutes. It's easy, so the bar is depth: the window algorithm without hesitation, then how far past it you go.
| Minutes | What you do | What to say or draw |
|---|---|---|
| 0 to 4 | Scope. | List sizes, whether the total is known, sharing, phones. |
| 4 to 7 | Pattern. | Why numbered pages: findable, shareable, a reachable footer. |
| 7 to 14 | Page window. | 7 slots at page 1, 5 and n; the three rules; pageWindow. |
| 14 to 18 | URL. | Links not buttons, canonical per page, clamping. |
| 18 to 23 | Totals and loading. | Four total kinds; keep previous, prefetch, races, focus. |
| 23 to 30 | API, accessibility. | getHref vs onChange, aria-current, compact mode, tests. |
Draw the wireframe first, then the architecture with the URL in the middle. Saying "the URL owns the page, not the control" early shortens every later answer.
Follow-up questions
Think first
New orders arrive while a user pages through with offset pagination. What goes wrong, and what would you do?
Newest first, 5 new orders push every row down by 5: page 2 repeats the last 5 rows of page 1. Deletes skip rows.
Usually acceptable in admin tables; show "5 new orders, refresh". If not, page by a stable key (created_at, id), which is cursor pagination, and give up jumping.
Think first
Someone requests ?page=50000 on the 3 million row table. Why is that slow even with an exact count, and how do you protect the database?
OFFSET 1249975 walks past 1.25 million rows to return 25. Cost grows with depth.
Cap the depth (GitHub search stops at 1,000 results), say "refine your filters" past it, and offer keyset jumps like "orders before March 3".
Check your understanding · Exercise 10
Which statements would strengthen an answer in the interview? Select all that apply.
Summary
- The control is a pure view of the URL and the total. It never fetches and never stores its own page.
- The window shows the ends, siblings around the current page, and an ellipsis only when it hides 2 or more pages. Its item count stays fixed while you page, so Next never moves.
- Every page is a real link with its own canonical URL. Clamp invalid pages on the server and redirect to the last real page.
- Totals come in four kinds: exact, estimate, at least, and unknown. Design the control for all four, because
COUNT(*)on millions of rows is often the slowest part of the request. - On a page change keep the previous rows dimmed, render only the response for the current page, prefetch the next page, scroll to the list heading and move focus there.
- Phones get a compact Previous, "Page 3 of 117", Next layout chosen by a container query.
- Accessibility is a labelled
nav,aria-current="page", full names like "Page 4", disabled ends that keep their place, and targets of at least 24 by 24 pixels.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Google Search Central: Pagination, incremental page loading, and their impact on Google Search Canonical URLs per page, no fragments, and the note that rel=next and rel=prev are no longer used.
- MUI: React Pagination component The siblingCount and boundaryCount props and the headless usePagination hook.
- Ant Design: Pagination Page size changer, quick jumper, and the simple mode.
- TanStack Query: Paginated queries placeholderData with keepPreviousData and isPlaceholderData.
- MDN: aria-current
- MDN: the nav element
- WAI-ARIA APG: Landmark regions Labelling more than one navigation landmark on a page.
- Understanding WCAG 2.2: Target Size (Minimum)
- PostgreSQL wiki: Count estimate Why exact counts are slow and how to get a cheap estimate.
- Elasticsearch: The search API, track total hits Exact counts up to 10,000 hits by default, then a lower bound with relation gte.
- GitHub REST API: Search Up to 1,000 results for each search.
Concepts behind it
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Right-to-left layoutsHow to build layouts that mirror correctly for Arabic, Hebrew, Persian, and Urdu readers: the
dirattribute, CSS logical properties, which icons to flip, text that mixes both directions, and how to test all of it. - Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.