How do you serve last/before on a GraphQL connection without returning edges reversed?
answer
- The store cannot read a tail cheaply
- Flip the scan, not the contract
- One step everybody forgets
- Fetch order versus response order
- Same window, two argument sets, identical edges
basics
~20 sFlip the ordering and the keyset predicate to read the tail, take N rows, then reverse those rows back before building edges. The fetch order is an implementation detail; the response must stay in the connection's declared order.
solid answer
~50 sA store that walks an order from its start cannot cheaply hand you a tail, so backward paging is normally served by flipping the read: decode the `before` cursor into the ordering key, build the predicate on the earlier side of it, order the rows the opposite way from the connection's declared order, and take N. That fetch is in reverse, so the last step is to **reverse the fetched rows back** before turning them into edges. Skipping it returns the correct set of edges in the wrong sequence — a bug that only appears on backward pages, which is why it survives testing. The Relay pagination algorithm never reverses anything; it only removes edges from one end or the other, so `last: 20` means the final twenty *in the connection's order*. `last` combined with `after` uses the same reversed scan with the `after` key as a floor, never a fetch-everything-then-trim.
code
pseudocode · 13 linesfunction backwardPage(orderKeyOf, beforeCursor, afterCursor, count):
ceiling = beforeCursor ? decode(beforeCursor) : NONE
floor = afterCursor ? decode(afterCursor) : NONE
# read the tail by walking the order the other way
rows = store.scan(
where = [key < ceiling if ceiling, key > floor if floor],
orderBy = descending(connectionOrder),
limit = count
)
rows = reverse(rows) # back into the connection's order
return [ edge(node = r, cursor = encode(orderKeyOf(r))) for r in rows ]go deeper
Recall that last asks for the tail of the list in normal order, not a reversed list, and that anything the server does to fetch those rows is invisible to the client.
Explain the flipped predicate and flipped ordering, and then name the reversal back as a required step. Being able to say why the bug only shows on backward pages is the mechanic being probed.
Diagnose from the symptom: correct rows, wrong sequence, one direction only. Show the keyset scan for last with after, and the test that concatenates backward pages and compares against the forward sequence.
Own it as a boundary decision — the store's read strategy must not leak into the published contract — and decide when the right answer is to refuse backward paging on a field rather than implement a window your store cannot serve within its cost budget.
## Why backward paging needs a different scan Forward paging maps onto an ordered read almost for free. The connection has an order, the store can walk that order from a bound, and `first: N` is the first N rows it hands you. Backward paging asks for the **tail** of a range, and a store that can only walk an order from its start has no cheap way to produce a tail: counting to the end first is exactly the cost that cursor paging exists to avoid. The standard implementation flips the read instead. To serve `last: 20, before: <cursor>` on a campaign's donation feed you: 1. Decode the cursor into the connection's ordering key. 2. Build the predicate in the *opposite* direction from the forward case — rows strictly on the earlier side of that key. 3. Order the rows the *opposite* way from the connection's declared order. 4. Take 20. 5. **Reverse the 20 rows you fetched** before turning them into edges. Step five is the whole question. Steps one to four are a mechanical mirror of forward paging, and they are what most people write. Step five is invisible in the query, has no representative in the schema, and is where the bug lives. (This assumes the ordering key already gives a deterministic total order — a separate concern, and one the cursor's design has to have settled before any of this works.) ## The symptom Skip the reversal and the connection still returns the right *set* of edges — the correct 20 rows, the correct cursors, the correct page boundaries. Only the sequence is wrong, and only on backward pages. So the bug ships. A donations list that reads newest-first everywhere renders oldest-first the moment a user clicks "previous", and because forward paging is what everyone tests, the report arrives from a user rather than from CI. It is worth being explicit about why this is not a specification quirk. The Relay pagination algorithm operates on the connection's ordered edges and only ever *removes* edges from one end or the other. Reversal appears nowhere in it. `last: 20` means the final twenty **in the connection's order**, and a server that returns them descending has violated the contract, not interpreted it. ## `last` together with `after` The awkward case is `last: 20, after: <cursor>` — the final twenty edges of everything that follows a cursor. It is legal by the algorithm, and it defeats the naive mirror, because the range now has a floor from `after` and its ceiling is the end of the relation. A keyset implementation handles it the same way as any other backward window: scan in the reversed order from the end of the relation, applying the `after` key as a floor rather than as a starting point, take twenty, and reverse the result. What you must not do is fetch every row after the cursor and trim in memory, which is precisely the unbounded read the connection shape was supposed to prevent. If your store genuinely cannot express the reversed scan, refusing the combination with a clear error is a better answer than quietly buffering. ## Cursors are not affected by the direction of the scan One more slip is worth naming. The cursor emitted for an edge identifies that edge's position in the connection's ordering. It does not encode which direction the client happened to be paging when it was produced. If a server builds cursors from the reversed scan — offsets counted from the end, or a direction flag baked in — the same edge gets a different cursor depending on how you arrived at it, and a client that pages backward and then forward from the cursor it just received lands somewhere unexpected. Emit cursors from the edge's position in the declared order and the round trip works in both directions. ## Proving it The test that catches this is small and specific, and it is worth writing before the implementation: take a fixed sequence of edges, page forward to the end, then page backward with `last` and `before`, and assert that the concatenated backward pages, read in order, reconstruct the original sequence exactly. Asserting only that the right *nodes* came back — a set comparison, which is what most generated tests do — passes happily against reversed pages. A second, cheaper check: for any window that both directions can address, `after: <D3>, first: 3` and `before: <D7>, last: 3` over the same data must return byte-identical edge lists. If they do not, one of the two paths is wrong, and it is almost always the backward one. ## What an interviewer is listening for Two things. First, that you know the fetch order and the response order are different concerns — that flipping the scan is an implementation detail the client must never see. Second, that you reach for a keyset predicate rather than counting from the end, because the reason connections exist at all is to keep the cost of page N independent of N.
- How would you catch this bug in a test?Page forward through a fixed sequence to the end, then page backward with `last` and `before`, and assert that the concatenated backward pages reconstruct the original sequence exactly. A set comparison — the same nodes came back — passes against reversed pages, which is why generated tests miss it. A second cheap assertion: a window addressable both ways must return identical edge lists from `after`/`first` and from `before`/`last`.
- What does `last: 20, after: <cursor>` require of the implementation?The final twenty edges of everything after that cursor. The range now has a floor from `after` and its ceiling is the end of the relation, so the reversed scan starts at the end and uses the `after` key as a lower bound rather than as a starting point. Fetching every row after the cursor and trimming in memory is the unbounded read the connection shape exists to prevent; if the store cannot express the reversed scan, an explicit error beats quiet buffering.
- Should the cursor an edge carries depend on which direction the client was paging?No. A cursor identifies an edge's position in the connection's declared ordering, nothing else. If cursors are built from the reversed scan — counted from the end, or carrying a direction flag — the same edge gets different cursors depending on how the client arrived at it, and paging backward then forward from the cursor just received lands somewhere unexpected.
saying these in an interview costs you the question
- Returns the reversed fetch order to the client
- Counts from the end instead of using a keyset predicate
- Says the specification defines backward pages as reversed
- Fetches everything after a cursor then trims in memory
- Encodes the paging direction into the cursor
- Tests only that the right nodes came back