The API pagination bug that looked like a frontend issue
Items vanished and duplicated as users scrolled a list. Everyone blamed the frontend for two sprints. The bug was an unstable sort in the API. Here's the case study.
This is a case study, details blurred, about a bug that hid behind the wrong layer for two sprints because everyone trusted their assumptions about where it lived. It's a good lesson in following the symptom down the stack instead of stopping at the first plausible culprit.
Context
A list view — orders, in this case — with infinite scroll. The frontend loaded a page of results, and as the user scrolled, fetched the next page from a paginated API (GET /orders?page=2&sort=createdAt). Standard pattern, shipped for months.
Symptoms
Users reported that scrolling a long list was "glitchy": occasionally an order appeared twice, and occasionally one they'd seen at the top was missing further down. It was intermittent, hard to reproduce on demand, and seemed to happen more on busy accounts. Classic "is it even a bug?" territory.
Investigation (down the wrong path first)
The infinite-scroll component was the obvious suspect — duplicate keys, a race in the scroll handler, state mishandling on fast scroll. Two sprints went into hardening the frontend: dedup logic, scroll debouncing, key fixes. The glitch got less frequent (the dedup masked some of it) but never went away — which, in hindsight, was the tell. Masking a symptom without it fully disappearing usually means you're treating the wrong layer.
Eventually someone tested the API directly, the way testing an API before the UI would have caught it on day one: request page 1, then page 2, and compare the IDs. Sometimes an order on page 1 also appeared on page 2; sometimes one fell between the pages entirely. The API itself was returning overlapping and gapped pages. The frontend was faithfully rendering corrupt data.
Root cause
The API sorted by createdAt — a timestamp — using offset pagination (LIMIT/OFFSET). On a busy account, many orders shared the same createdAt second. With a non-unique sort key and no tiebreaker, the database was free to order tied rows differently on each query. So between the page-1 request and the page-2 request, the tied rows reshuffled, and the offset landed in a different place — duplicating some rows across the boundary and skipping others. Exactly the unstable-sort failure that pagination amplifies. More tied rows (busy accounts) → more reshuffling → more glitches.
What the tests missed
UI tests scrolled short, quiet lists where ties were rare and the bug almost never fired. There was no API-level test that paged through a list and asserted the invariant — every record appears exactly once across all pages. And the mental model ("a list bug is a frontend bug") meant nobody pointed testing at the API for two sprints. The fix was one line of backend code (add a unique tiebreaker — sort=createdAt,id); finding it was the hard part.
The reusable lesson
A "frontend" symptom isn't proof of a frontend cause — follow it down the stack. For any list endpoint, test pagination at the API and assert the no-duplicates/no-gaps invariant, especially against data with tied sort values. And when a fix only reduces a symptom instead of eliminating it, suspect you're masking it at the wrong layer.
Pagination case lessons
- Test pagination at the API, not just through the UI — page through and compare IDs
- Assert the invariant: every record appears exactly once across all pages
- Sort on data with TIED values; an unstable sort only corrupts pages when ties exist
- Require a unique tiebreaker on any sort used for pagination (
sort=field,id) - When a fix only reduces (not removes) a symptom, suspect the wrong layer
- Follow the symptom down the stack — a "frontend glitch" can be a backend bug
// RELATED QA.CODES RESOURCES
Checklist
Common Bug
// related
The bug that only happened after daylight saving time changed
A case study: a scheduling bug that stayed invisible until the clocks changed — and the test scenarios that would have caught it.
The checkout bug that passed every happy-path test
Every checkout test was green, but combining two discounts and a gift card drove the total negative — and issued credit. A case study in testing invariants, not just features.