Common Pagination Bugs and How to Catch Them
Common Pagination Bugs and How to Catch Them
Common Pagination Bugs and How to Catch Them
Pagination is a seemingly simple feature: split a large data set into bite‑size chunks and let users move forward or backward. In practice, the mechanics hide a surprising number of edge cases that slip past unit tests, pass manual sanity checks, and only reveal themselves under real‑world load or with specific user behaviors. This guide walks through the most frequent pagination defects, explains why they arise, shows how they appear to users, and gives concrete steps to reproduce, detect, fix, and prevent them.
---
Common Pagination Bugs and How to Catch Them: Overview
Before diving into specific patterns, it helps to view pagination as a contract between three parties: the data source (database, search index, or service), the transport layer (API endpoint or frontend component), and the presentation layer (list, infinite scroll, or page‑number control). A bug can violate any part of that contract.
Typical failure modes include:
- Missing or duplicated items – the same record appears on two pages or never appears at all.
- Incorrect total count – the UI shows “1‑20 of 150” when there are actually 148 records.
- Stale state after mutation – inserting or deleting a record while a user is paging causes the next page to shift unexpectedly.
- Performance collapse – a naïve offset‑limit query scans millions of rows on each page turn.
- Accessibility gaps – keyboard focus jumps to the wrong element or screen readers announce the wrong page number.
Detecting these issues requires a mix of static analysis, targeted unit tests, and dynamic exploration that mimics real user journeys. The sections below break down the most common bug families, give reproducible examples, and show how to catch them with both manual and automated techniques.
---
Common Pagination Bugs and How to Catch Them in Offset‑Limit APIs
Offset‑limit pagination (often called “skip/take”) is the default for many SQL‑based APIs. Its simplicity masks several subtle pitfalls.
Why offset‑limit fails
When a client requests GET /items?offset=20&limit=10, the server executes something like SELECT * FROM items ORDER BY id LIMIT 10 OFFSET 20. If rows are inserted or deleted between requests, the logical slice shifts. A new row inserted before offset 20 pushes every existing row down by one, causing the item that was previously at position 20 to appear again on page 2. Conversely, a deletion removes a row and creates a gap that makes the client skip an item.
Symptoms to users
- Seeing the same item on two consecutive pages.
- Noticing that an item disappears entirely after paging forward.
- Observing a sudden jump in item IDs (e.g., page 1 ends with ID 105, page 2 starts with ID 108).
Reproduction steps
- Seed a table with 100 rows, ordered by an auto‑increment
id. - Request page 1 (
offset=0, limit=10) and record the IDs returned. - Insert a new row with
idless than the current offset (e.g.,id=5). - Request page 2 (
offset=10, limit=10). - Compare the IDs; you will see the item that previously belonged to page 1 now appearing on page 2.
Detection techniques
- Unit test with mutation – write a test that inserts a row between two API calls and asserts that the union of pages contains no duplicates and no missing IDs from the original set.
- Property‑based testing – generate random insert/delete sequences and verify that the concatenated pages equal the original ordered set.
- SQL explain plan – ensure the query uses an indexed column for ordering; a full table scan indicates a performance bug that will worsen as data grows.
Fixes
- Keyset (seek) pagination – replace offset with a cursor based on the last seen value of a unique, ordered column:
SELECT * FROM items WHERE id > :last_id ORDER BY id LIMIT :limit. This is immune to insertions/deletions before the cursor. - Stable snapshot – for short‑lived sessions, use a transactional snapshot (
SELECT … FROM items WITH (SNAPSHOT)) or a temporary table that captures the state at the start of pagination. - Versioned tokens – embed a monotonic version token in the cursor; if the underlying data changes, the server returns a 410 Gone and forces the client to restart from the first page.
Prevention checklist
- [ ] Replace offset‑limit with keyset pagination for any API that may see writes during pagination.
- [ ] Add a unit test that inserts a row between two page requests and checks for duplicates/gaps.
- [ ] Verify that the ordering column is indexed and unique (or combine with a tie‑breaker).
- [ ] Document the pagination contract in the OpenAPI spec, including the cursor format.
---
Common Pagination Bugs and How to Catch Them in Cursor‑Based APIs
Cursor‑based pagination solves the offset problem but introduces its own failure modes, especially around cursor encoding, expiration, and ordering ties.
Why cursor pagination can break
A cursor is typically a base64‑encoded string that contains the value(s) of the ordering column(s) for the last item returned. If the encoding is mishandled (e.g., URL‑unsafe characters not escaped), the client may send a malformed cursor that the server treats as null, returning the first page again.
If the ordering columns are not unique, ties cause nondeterministic ordering: two rows with the same timestamp may appear in either order across pages, leading to duplicates or omissions.
Symptoms to users
- Clicking “Load more” returns the same set of items repeatedly.
- Seeing items jump positions when the server’s clock is adjusted (e.g., after a NTP sync).
- Receiving a 400 Bad Request with an opaque error when the cursor contains a
+sign that got turned into a space.
Reproduction steps
- Create two records with identical
created_attimestamps (e.g., both at2025-01-01T12:00:00Z). - Request the first page (
limit=5). The server returns records A, B, C, D, E (where A and B share the timestamp). - Encode the cursor using the timestamp of E and a tie‑breaker (e.g.,
id). If the implementation only uses the timestamp, the cursor is ambiguous. - Request the next page with that cursor; you may receive A and B again, or you may skip them entirely.
Detection techniques
- Unit test with tied ordering values – insert rows that share the cursor key and assert that the union of pages contains each row exactly once.
- Fuzzing cursor strings – generate random base64 strings, feed them to the endpoint, and verify that the server either returns a proper error (400) or treats them as invalid without crashing.
- Monitoring for HTTP 422/400 spikes – a sudden increase in malformed‑cursor errors often indicates a client‑side encoding bug.
Fixes
- Include a tie‑breaker – always add the primary key (or another unique column) to the cursor after the main ordering column(s).
- Use URL‑safe base64 – encode with the “urlsafe” variant and strip padding; decode with the same variant on the server.
- Version the cursor format – prefix the encoded payload with a version number (e.g.,
v1:). When the format changes, old cursors are rejected explicitly, prompting a client refresh. - Return the next cursor explicitly – instead of expecting the client to infer it, provide
next_cursorin the response body; this reduces client‑side logic errors.
Prevention checklist
- [ ] Ensure ordering columns are unique or combined with a unique tie‑breaker.
- [ ] Use URL‑safe base64 encoding/decoding consistently on client and server.
- [ ] Add unit tests that insert ties and verify no duplication/loss.
- [ ] Log rejected cursors with the raw value to aid debugging.
---
Common Pagination Bugs and How to Catch Them in Infinite Scroll UIs
Infinite scroll replaces explicit page controls with a sentinel that triggers a fetch when the user nears the bottom of the list. The UI layer introduces timing, state, and rendering bugs that are invisible in API‑only tests.
Why infinite scroll fails
- Premature trigger – the scroll listener fires before the previous request finishes, causing overlapping requests and duplicate renders.
- Stale scroll position – after a new batch arrives, the component may not adjust the scroll offset, making the user feel “stuck” or causing the list to jump.
- Missing loading indicator – users may think the app is frozen and scroll aggressively, generating many redundant requests.
- Incorrect sentinel logic – using
window.innerHeight + window.scrollY >= document.body.offsetHeightfails when dynamic content (e.g., ads) changes the document height asynchronously.
Symptoms to users
- Seeing the same item appear twice in rapid succession.
- The list jumping upward when new data loads.
- A blank area at the bottom where no more items appear, even though the server still has data.
- Excessive network traffic observed in dev tools (dozens of requests per second).
Reproduction steps (manual)
- Load a list that uses infinite scroll with a page size of 20.
- Scroll slowly until the trigger fires; note the network request.
- While the request is still pending, continue scrolling quickly to the bottom.
- Observe whether a second request is sent before the first resolves.
- After the first response renders, check if any item from the first batch appears again in the second batch.
Automated detection
- Puppeteer/Playwright script – scroll to
document.body.scrollHeight - 200every 300 ms, count network requests, and assert that no more than one request is in flight at any time. - Accessibility audit – ensure that a loading spinner is announced via
aria-live="polite"when data is fetching. - Visual regression – capture screenshots before and after a batch load; the scroll offset of a known element should remain constant (within a few pixels).
Fixes
- Debounce the scroll handler – wait at least 150 ms after a scroll event before checking the sentinel, and cancel the check if a new scroll occurs.
- Track request state – store a boolean
isFetching; prevent a new fetch while true. - Use IntersectionObserver – place a sentinel element at the bottom of the list and observe when it enters the viewport; this avoids manual height calculations.
- Adjust scroll offset after insert – when new items are appended, calculate the height added and adjust
scrollTopby that amount to keep the viewport stable.
Prevention checklist
- [ ] Replace manual scroll listeners with IntersectionObserver where possible.
- [ ] Guard fetch calls with a flag that prevents concurrent requests.
- [ ] Show a loading indicator with appropriate ARIA live region.
- [ ] Write an automated test that verifies no duplicate items appear after a scroll‑triggered fetch.
---
Common Pagination Bugs and How to Catch Them in Page‑Number Controls
Traditional page‑number widgets (e.g., “1 2 3 … 10 [Next]”) are common in admin dashboards and search results. Bugs here often revolve around stale page counts, incorrect handling of the last page, and accessibility oversights.
Why page‑number controls fail
- Out‑of‑date total – the UI renders page links based on a total count fetched at mount time; if records are added or removed, the last page number becomes wrong.
- Off‑by‑one in last page – calculating
lastPage = Math.ceil(total / pageSize)but then disabling the “Next” button whencurrentPage >= lastPagehides the last valid page. - Missing keyboard navigation – users cannot tab to page links or activate them with Enter/Space, violating WCAG 2.1.
- Page jump after filter change – applying a filter that reduces the result set while the user is on page 5 may leave them on a nonexistent page, showing a blank screen.
Symptoms to users
- Clicking “Next” does nothing, even though more items exist.
- Seeing a page number that is disabled despite having items.
- Keyboard users unable to reach the pagination controls.
- After a search, the UI shows “Page 5 of 0” or a blank list.
Reproduction steps
- Load a list with 95 items, page size = 20 (so 5 pages).
- Navigate to page 4 (items 61‑80).
- Delete 10 items from the range 81‑95 (now total = 85, 5 pages still, but last page holds only 5 items).
- Click “Next”. Observe whether the UI shows page 5 correctly or stays on page 4.
- Remove another 20 items (total = 65, now only 4 pages). Click “Next” again; the UI should either hide the button or show a message that there are no more pages.
Detection techniques
- Unit test for total‑count sync – after each mutation (insert/delete), re‑fetch the total and assert that the rendered page links match
Math.ceil(total / pageSize). - End‑to‑end test for filter reset – apply a filter, navigate to a middle page, clear the filter, and verify that the UI either redirects to page 1 or preserves a valid page number.
- AXE or aXe-core audit – run an accessibility scan on the pagination component to catch missing
role,aria-label, or keyboard handlers.
Fixes
- Re‑calculate total on every page change – fetch the total count (or maintain it via a lightweight counter) whenever the filter, sort, or pagination parameters change.
- Clamp current page – after computing
lastPage, setcurrentPage = Math.min(currentPage, lastPage). IfcurrentPagebecomes zero, redirect to page 1. - Disable “Next” only when
currentPage === lastPage– use a strict equality check, not>=. - Add keyboard support – ensure each page link is focusable (
tabindex="0"), hasrole="link", and responds tokeydownfor Enter/Space. - Show a “no results” state – when total is zero, hide the pagination widget and display a helpful message.
Prevention checklist
- [ ] Re‑fetch or update total count after any data‑mutating action.
- [ ] Clamp the current page to the valid range after total changes.
- [ ] Unit test the “Next/Prev” disabled state across edge totals (0, 1, exact multiple of pageSize).
- [ ] Run an accessibility audit on the pagination component in every UI build.
---
Common Pagination Bugs and How to Catch Them in Search Result Pages
Search paginates results based on relevance scores, filters, and faceted navigation. The interaction between ranking, faceting, and pagination creates bugs that are hard to anticipate with simple keyword tests.
Why search pagination fails
- Score drift – when a facet is applied, the underlying query changes; the sort order may shift, causing items that appeared on page 2 under the original query to move to page 1 after faceting, leading to perceived duplication.
- Facet count lag – the facet counts displayed in the sidebar are sometimes computed from the unfiltered result set, so selecting a facet shows a count that doesn’t match the actual number of items returned.
- Empty page after facet – a facet may filter out all remaining items on the current page, but the UI does not automatically move to the next non‑empty page, leaving the user staring at a blank list.
- Inconsistent tie‑breaking – search engines often sort by score then by document ID; if the ID tie‑breaker is not stable across shards, pagination can skip or repeat documents.
Symptoms to users
- Seeing the same product appear on two different pages after applying a brand filter.
- Facet sidebar showing “24 items” for a category, but the list shows only 12 after the facet is applied.
- After selecting a rare facet, the list goes blank and the user must manually click “Next” to see results.
- Facet counts changing unpredictably when paging through results.
Reproduction steps
- Index a collection of products with fields:
title,category,brand,price. - Execute a broad query (
"camera") withsort=score_descandpage_size=10. Record the first 20 results (pages 1‑2). - Apply a facet filter
brand:Canon. - Request page 1 again; compare the IDs of the first ten results with the first ten from step 2. Expect no overlap if the facet is restrictive.
- If overlap appears, note the duplicated IDs.
- Also request the facet counts before and after applying the filter; verify they match the actual hit counts.
Detection techniques
- End‑to‑end scenario test – automate a sequence: query → capture first N IDs → apply facet → capture first N IDs after facet → assert Jaccard similarity < 0.1 (i.e., minimal overlap).
- Facet count validation – after each facet selection, compute the hit count from the search response and compare it to the displayed facet count; fail if the difference exceeds a tolerance (e.g., 5 %).
- Shard isolation test – if using a distributed search engine, force a single‑shard request and compare pagination results to the multi‑shard version to detect tie‑breaker inconsistencies.
Fixes
- Stable sort key – add a deterministic secondary sort (e.g.,
_docor_id) to the search query to guarantee repeatable ordering across shards and after facet changes. - Re‑compute facet counts on filtered set – ensure the facet aggregation runs after the query filters, not before.
- Auto‑advance on empty page – when a page returns zero hits, automatically increment the page number and fetch again until a non‑empty page is found or the end is reached.
- Explicit pagination token – return a search
scroll_idorsearch_aftertoken that encodes the exact sort values; the client uses it for the next request, eliminating reliance on page numbers.
Prevention checklist
- [ ] Include a tie‑breaker field in the search sort clause.
- [ ] Verify that facet aggregations are post‑filter.
- [ ] Add an automated test that applies a facet and asserts no duplicate items across pages.
- [ ] Log any zero‑hit page responses and trigger a retry with the next page token.
---
Using Persona‑Driven Autonomous Exploration to Surface Pagination Bugs
Scripted tests excel at checking known paths, but they often miss the combinations of user behavior, data mutation, and timing that expose pagination flaws. Autonomous QA platforms—like SUSATest—can simulate a variety of user personas, each with distinct interaction patterns, and thereby uncover bugs that remain hidden in conventional test suites.
How personas affect pagination
| Persona | Typical behavior | Pagination‑specific risk |
|---|---|---|
| Curious | Taps every item, scrolls quickly, opens filters | May trigger rapid successive page requests, exposing race conditions |
| Impatient | Scrolls to bottom instantly, clicks “Load more” repeatedly | Can cause overlapping requests or exhaust server rate limits |
| Novice | Relies on visible page numbers, rarely uses infinite scroll | May notice off‑by‑one errors in page‑number controls |
| Adversarial | Enters malformed cursors, attempts SQL injection via pagination params | Can reveal insufficient input validation or cursor‑ parsing bugs |
| Elderly / Accessibility | Uses keyboard navigation, screen reader, high contrast | May uncover missing ARIA labels, focus traps, or insufficient touch targets |
| Power user | Applies multiple facets, sorts, and paginates deeply | Can expose stale total counts, facet‑count mismatches, or deep‑page performance issues |
| Data‑mutating | Inserts, deletes, or updates records while browsing | Highlights offset‑limit drift, keyset expiration, or snapshot inconsistencies |
When an autonomous agent explores an app with these profiles, it dynamically varies:
- Input values (e.g., cursor strings, page numbers, filter combinations).
- Timing (delays between scrolls, rapid bursts).
- Data state (inserts/delete before or after a page request).
The agent records every network call, UI state change, and console error, then evaluates heuristics such as: duplicate item detection, missing items after a known insert, or sudden jumps in scroll position.
Concrete example with SUSATest
Suppose we have an e‑commerce infinite‑scroll product list. We configure a persona “Impatient” with a scroll burst of 500 px every 200 ms and a “Data‑mutating” persona that inserts a new product every 3 seconds via a background API.
The SUSATest agent runs the following pseudo‑flow:
// SUSATest persona script (simplified)
await page.goto('https://shop.example.com/products');
let lastY = 0;
while (!page.isFinished()) {
// Impatient scroll burst
await page.evaluate(() => window.scrollBy(0, 500));
await page.waitForTimeout(200);
// Data‑mutating action (runs in parallel)
await page.request.post('/admin/products', {
data: { name: 'TestItem', price: 9.99 }
});
// Check for duplicates
const ids = await page.$$eval('.product-card', els => els.map(e => e.dataset.id));
if (new Set(ids).size !== ids.length) {
page.reportBug('Duplicate product IDs detected during infinite scroll');
}
}
If the backend uses offset‑limit pagination, the injected items will cause the visible product IDs to shift, and the duplicate check will fire. The same script run against a keyset‑based endpoint would pass, demonstrating how autonomous exploration can validate the effectiveness of a fix.
Benefits over scripted tests
- Coverage of interleaving actions – the agent naturally mixes UI gestures with background API calls, something a static test script would need to explicitly schedule.
- Discovery of unexpected edge cases – e.g., a user who rapidly toggles a facet while scrolling may cause a race condition that no manual tester thought to script.
- Continuous learning – each run builds a knowledge base of visited screens and dead ends; subsequent runs focus on unexplored areas, increasing the chance of hitting a pagination boundary condition.
Practical tips for integrating autonomous exploration
- Seed realistic personas – start with the six profiles above and adjust parameters (scroll speed, mutation rate) to match your product’s usage analytics.
- Instrument duplicate detection – embed a lightweight hash of visible item IDs into the window object; the test harness can read it after each interaction.
- Fail fast on performance degradation – monitor response times for pagination requests; a sudden increase beyond a threshold (e.g., 2× baseline) flags a potential offset‑limit scan issue.
- Correlate with logs – send the agent’s session ID to your backend logs; you can then trace which server‑side query produced duplicate or missing rows.
---
Practical Test Matrix for Pagination Validation
Below is a matrix that maps common pagination scenarios to manual validation steps, automated checks, and the tools best suited for each. Use this as a starting point when building a test suite for a new endpoint or UI component.
| Scenario | Manual validation steps | Automated check (example) | Tools / Frameworks |
|---|---|---|---|
| Offset‑limit API with concurrent inserts | 1. Load page 1, note IDs. 2. Insert a row with ID < offset. 3. Load page 2, verify no duplicate/missing IDs. | `jstest('offset limit duplicate detection', async () => { const page1 = await api.get('/items?offset=0&limit=20'); await db.insert({id:5, ...}); const page2 = await api.get('/items?offset=20&limit=20'); const ids = [...page1.data.map(i=>i.id), ...page2.data.map(i=>i.id)]; expect(new Set(ids).size).toBe(ids.length); }); ` | Jest / Mocha + Supertest, any SQL DB fixture |
| Cursor‑based API with tie‑breaking | 1. Create two rows with same timestamp, different IDs. 2. Fetch first page, capture cursor. 3. Fetch second page using cursor; ensure no overlap. | `jstest('cursor tie breaker', async () => { await db.createMany([{ts:now, id:1},{ts:now, id:2}]); const p1 = await api.get('/items?limit=1&sort=ts'); const cursor = encodeCursor(p1.data[0].ts, p1.data[0].id); const p2 = await api.get( /items?limit=1&sort=ts&cursor=${cursor});expect(p2.data[0].id).toBe(2); }); ` | Playwright API test, Node |
| Infinite scroll list (UI) | 1. Scroll to trigger load. 2. While loading, scroll further quickly. 3. Verify no duplicate item IDs appear. | `jstest('infinite scroll no duplicates', async () => { await page.goto('/feed'); let seen = new Set(); await page.evaluate(async () => { let resolve; const wait = new Promise(r=>resolve=r); const observer = new IntersectionObserver((entries)=>{ if(entries[0].isIntersecting){ resolve(); } },{threshold:0.1}); const sentinel = document.createElement('div'); document.body.appendChild(sentinel); observer.observe(sentinel); await wait; }); const ids = await page.$$eval('.item', els=>els.map(e=>e.dataset.id)); expect(new Set(ids).size).toBe(ids.length); }); ` | Playwright + IntersectionObserver polyfill |
| Page‑number control with total updates | 1. Navigate to middle page. 2. Delete enough items to shrink total pages. 3. Click “Next”; verify correct page or disabled button. | `jstest('pagination clamps after delete', async () => { await page.goto('/admin/users'); await page.selectOption('#pageSize','20'); await page.click('text=Page 3'); await db.deleteMany({id:{$gt:80}}); // reduce total await page.click('text=Next'); const disabled = await page.isDisabled('button[aria-label="Next"]'); expect(disabled).toBe(true); }); ` | Jest + Puppeteer, API for DB mutation |
| Search results with facet & pagination | 1. Perform broad query, capture first 20 IDs. 2. Apply a facet, capture first 20 IDs after facet. 3. Assert low overlap. 4. Validate facet count matches hit count. | `jstest('search facet pagination consistency', async () => { const r1 = await searchApi.get('/search?q=camera&limit=20'); const facet = {brand:'Canon'}; const r2 = await searchApi.get( /search?q=camera&limit=20&fbrand=${encodeURIComponent(facet.brand)});const overlap = r1.data.filter(i=>r2.data.some(j=>j.id===i.id)).length; expect(overlap).toBeLessThan(3); const facetCount = await searchApi.get( /search/facets?q=camera&fbrand=${encodeURIComponent(facet.brand)});expect(facetCount.data[0].count).toBe(r2.data.length); }); ` | Jest + fetch, Elasticsearch/OpenSearch mock |
| Keyset API expiration after update | 1. Fetch first page, capture last sort value. 2. Update the sorted column of the last item on the page.<>3. Request next page using old cursor; expect 410 Gone or empty. | `jstest('keyset expiration', async () => { const p1 = await api.get('/items?limit=10&sort=updated_at'); const last = p1.data[p1.data.length-1]; await db.update(last.id, {updated_at: new Date()}); const cursor = encodeCursor(last.updated_at, last.id); const p2 = await api.get( /items?limit=10&sort=updated_at&cursor=${cursor});expect(p2.status).toBe(410); // or empty array }); ` | AVA + supertest |
The matrix makes it clear that a combination of manual spot checks (good for exploratory testing) and automated assertions (essential for CI) yields the strongest safety net.
---
Fix Strategies and Prevention Checklist
Having examined the bugs, their origins, and detection methods, we now consolidate remediation into a concise, actionable checklist that can be woven into your Definition of Done (DoD) or sprint‑planning routine.
1. Choose the right pagination model early
- If the dataset is append‑only or rarely mutated – offset‑limit may be acceptable for low‑traffic admin tools, but add a comment in the code warning future maintainers.
- For any user‑facing list that sees writes – default to keyset (seek) pagination.
- When you need bidirectional navigation (jump to arbitrary page) – consider hybrid approaches:
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free