skip to content

In a normalized cache, which arguments of a paginated list field belong in the storage key?

level: seniorimportance: should knowfreq 34%

answer

  1. Not every argument does the same job
  2. Which set, versus which window
  3. Filters and sort identify the list
  4. A per-request timestamp poisons the key

basics

~20 s

Arguments that decide which set of items the field denotes stay in the key: filters, sort order, scope. Arguments that only choose a window into that set are dropped, so every page accumulates into one entry.

solid answer

~50 s

Split the arguments by what they do. **Identity arguments** change which items the field means — a status filter, a sort field and direction, a date range, the account the list is scoped to — and must stay in the key, or two genuinely different lists collide in one entry and the merge rule interleaves them. **Window arguments** — a cursor, an offset, a page size — choose a slice of an already-identified list and must be excluded, or every page fragments into its own entry and the merge never runs. A third group is worth hunting for: arguments that vary per request without changing the answer, such as a timestamp or a correlation id. Left in the key they mint a fresh entry on every call, so the cache never hits. Every one of these mistakes is silent — you meet them as mixed rows, replaced lists, or a store that never warms.

code

graphql · 9 lines
graphql
query Statements($status: PayStatementStatus!, $after: String) {
  employee(id: "emp_4471") {
    # status and sort identify the list; first and after only window it
    payStatements(status: $status, sort: PERIOD_END_DESC, first: 25, after: $after) {
      edges { node { id periodEnd netPay } }
      pageInfo { endCursor hasNextPage }
    }
  }
}

go deeper

for a junior

Learn the two categories by example: a status filter changes which rows exist, a cursor only changes where you are in them. Knowing that only the first kind belongs in the key is enough here.

for a middle

Be able to sort a real field's arguments into identity and window groups and say what breaks in each direction — mixed rows when a filter is dropped, fragmented entries when a cursor is kept.

for a senior

Show production judgement: catching a cache that never hits because an unstable argument is in the key, knowing sort must reset the entry, and resetting the store on identity change rather than encoding a viewer into every key.

for a principal

Own the policy — a house rule for key arguments on every paginated field, a retention and eviction budget for accumulated lists, and a stance on which arguments should never reach the request at all.

## One argument list doing two different jobs A paginated field's arguments look uniform in the document and are not. Some of them decide **which set of items the field denotes**; the rest decide **which window of that set you are being handed**. A normalized cache's storage key should capture the first group and nothing else, because the key is meant to be the identity of the list and the merge rule is meant to accumulate windows into it. **Identity arguments** — keep them in the key: - filters: `status: PAID`, `planYear: 2026`, a date range, a search term; - ordering: the sort field and direction; - scope: the account, employer or viewer the list belongs to, when it is expressed as an argument. **Window arguments** — leave them out: - `after` / `before` cursors, `offset` / `skip`; - `first` / `last` / `limit` page size. ```graphql query Statements($status: PayStatementStatus!, $after: String) { employee(id: "emp_4471") { # status and sort identify the list; first and after only window it payStatements(status: $status, sort: PERIOD_END_DESC, first: 25, after: $after) { edges { node { id periodEnd netPay } } pageInfo { endCursor hasNextPage } } } } ``` ## The failure when you drop too much A payroll team excluded `after` from the key — correctly — and excluded `status` at the same time, on the reasoning that the screen only ever shows one tab at a time. It does, but the cache does not know that. Switching from the PAID tab to the VOIDED tab wrote a different set of rows into the same entry, and the merge rule appended, so the list showed paid statements followed by voided ones under a heading that claimed one status. It got worse when the schema gained a new member on the status enum, `REVERSED`, that the tab bar had no branch for. Rows of a status the renderer's switch fell through on now sat inside an accumulated list that no longer corresponded to any single filter, showing as blank rows partway down; and because the entry never reset on a tab change it grew until the list view missed its 340 ms p99 render budget. The fix was one line — put `status` back in the key. Each tab then owns its own accumulating entry, and switching back to a tab is instant because its rows are still there. ## The failure when you keep too much The opposite mistake is the default. Leave the cursor in the key and every page becomes a separate entry, so the merge rule never runs, "load more" appears to replace the list, and memory holds one reference list per page. There is a third category that is easy to miss: arguments that vary per request without changing the answer. A `now:` timestamp used for relative filtering, a client-generated request id, a correlation value added for tracing. Left in the key, each of them mints a fresh entry on every call, so the cache never hits, the store grows for the whole session, and the symptom reads as "the cache does nothing" or as a memory leak. The best fix is usually to stop sending the argument. ## Sort order deserves its own paragraph Sort is an identity argument, and cursor pagination makes that structural rather than aesthetic: a cursor is only meaningful within the ordering that produced it. If the sort argument is left out of the key, changing the sort accumulates the new ordering into the entry built under the old one, and the next cursor is applied against an ordering the accumulated list no longer reflects. Keeping sort in the key makes the change automatic — new key, empty entry, a clean first page. ## Viewer scope and identity changes A list scoped to a person is only safe to keep for as long as that person is the one looking. Encoding the viewer into every field's key is possible but brittle; the usual discipline is coarser and stronger — reset the whole store when the signed-in identity changes, on sign-in and sign-out alike, so nothing cached under one account can be read under another. ## A mechanical way to decide For each argument, ask: *if I changed only this, would the server return a different set of items, or a different window on the same set?* Different set means it belongs in the key. Different window means it belongs to the merge rule. Neither means it should not be an argument on the request at all. ## The cost of getting it right Keeping identity arguments in the key is correct and not free: every distinct combination retains its own accumulated list for the session. A filter panel with a free-text search box will mint an entry per keystroke unless that argument is debounced, and a rich filter UI can hold dozens of accumulated lists at once. Budget for it — evict on route change, cap retained combinations, and reset on sign-out. None of this is specified. The GraphQL specification defines the type system, documents and execution; storage keys, key-argument selection and merge rules are conventions of client caches, and the vocabulary differs between them even where the mechanism does not.

  • Where does page size belong — the key or the merge rule?
    The merge rule. Page size chooses how much of a window you take, not which list it comes from, so keying on it fragments the entry the moment a screen asks for a different amount. The merge still wants it as an input, because offset arithmetic and position checks depend on it, and mixing sizes within one entry is what makes that arithmetic delicate.
  • A user changes the sort order halfway down a long list. What must happen to the accumulated entry?
    It must not be extended. Sort is an identity argument, so keeping it in the key gives the new ordering a fresh, empty entry automatically — which is what you want, because cursors produced under the old ordering are not comparable with the new one. If sort was wrongly excluded, the entry has to be reset explicitly before the first page arrives.
  • How do you stop per-filter entries from accumulating without bound over a session?
    Accept that each identity-argument combination retains its own list, then bound it: debounce or exclude free-text search arguments so a keystroke does not mint an entry, cap how many combinations are retained, evict on route change or unmount, and reset the whole store when the signed-in identity changes.

The filter says which shelf you are reading; the cursor says how far along that shelf you have got. The shelf label belongs on the box, your bookmark does not.

saying these in an interview costs you the question

  • Drops the filter argument along with the cursor
  • Keeps every argument in the key, cursor included
  • Thinks a sort change can reuse the accumulated list
  • Puts a per-request timestamp in the key
  • Assumes cached lists are safe across a sign-in change
  • Calls a never-hitting cache a server problem

context