Meet Modi
Back to Blog
·5 min·updated

The endpoint said total: 0 and had 267 rows

A library tab showed 30 of 267 notebooks. The pagination block answered total: 0, has_more: false on every page, while the pages themselves were correct.

By Meet Modi
PaginationAPI ContractsFrontendDefensive CodingDebugging

If an endpoint's pagination metadata is wrong but its pages are right, trust the pages. Treat a full page as evidence that more may exist and stop on the first short page. That rule is self-limiting, needs no release to undo when the endpoint is fixed, and would have avoided a tab that showed 30 of 267 items and called it the whole library.

What was broken

A student's notebook library listed 30 items. They had 267. The fetch loop was reading has_more, which was false, so it stopped after the first page and considered the job done.

The endpoint's pages were perfectly fine. Offset 240 of 267 returned the last 27 rows, exactly as it should. Only the metadata describing them was wrong, and it was wrong identically on every page.

GET /user/files?offset=0&limit=30
{
  "rows": [ /* 30 correct items */ ],
  "pagination": { "total": 0, "has_more": false },
  "counts": { "self": 0, "teacher": 0, "by_class": [] }
}

Three separate lies in one response. total: 0 over 267 rows. has_more: false with 237 left. And facet counts of zero on a response where every row returned was a self upload.

Infer from the page, not from the metadata

A full page is weak evidence that another might exist. A short page is strong evidence that it does not, because an endpoint that can return fewer rows than you asked for and still have more is broken in a way nothing can paper over.

async function fetchAll(limit = 30) {
  const rows = [];
  for (let offset = 0; ; offset += limit) {
    const page = await getPage({ offset, limit });
    rows.push(...page.rows);

    // The flag still wins when it is set, so a fixed endpoint needs
    // no change here. A short page always ends the pull.
    if (page.pagination?.has_more === true) continue;
    if (page.rows.length < limit) break;
    if (rows.length >= HARD_CAP) break;   // never trust yourself either
  }
  return rows;
}

Two properties make this safe to ship. It is self-limiting, because the first short page ends it regardless of what the metadata claims. And it defers to the flag when the flag is set to true, so the day the endpoint starts reporting honestly this code keeps working and nobody has to remember to remove it.

Displaying a number you do not have

The header read "Showing 8 of 0" over a library of thirty, because the whole-library total came straight from a counts block the endpoint often omitted entirely.

The rows you are holding are a floor under the real total. Taking the larger of the two turns a wrong number into an incomplete one, and the line already said the rest was still loading.

const total = Math.max(counts?.all ?? 0, rowsInHand.length);

Same reasoning for the filter chips. A chip reading 0 next to thirty of your own notebooks is a wrong number. A chip counted over the part of the library fetched so far is a number still arriving. So the chips fall back to counting rows in hand whenever the facets do not add up, and the class dropdown appends any class its rows can name that the facets failed to list.

None of this is the fix

The endpoint should send its own pagination correctly, and that is filed. What a client can do is decide how to behave while that is true, and there is a real difference between the two options.

  • Trust the metadata and show a third of someone's library with no indication anything is missing. Silent, and indistinguishable from an empty account.
  • Infer from the pages and show everything, with counts that may lag. Visibly approximate, never wrong about what exists.

The second is better for the same reason an error that names the real cause beats a generic one. Verified against a live environment afterwards: nine pages, 267 unique rows, no duplicates. Related: the switch statement that blamed your Wi-Fi.

More Posts