ETag and 304 Not Modified are about one thing: “Is the representation I would send you the same as what you already have?”
If your representation contains both:
- the rows
- metadata like
totalCount,totalPages,hasNext, etc.
then metadata changes means the representation changed, even if the rows did not.
Example
API response includes both rows and metadata:
{ "items": [1,2,3,4,5], "page": 1, "pageSize": 5, "totalPages": 2 }
Client requests page 1. Server computes:
- items = [1,2,3,4,5]
- totalPages = 2
ETag = hash of the whole response.
Now someone inserts more items. Page 1 rows might still be [1,2,3,4,5] because new items fall into later pages.
But totalPages becomes 3.
New response would be:
{ "items": [1,2,3,4,5], "page": 1, "pageSize": 5, "totalPages": 3 }
If the server returns 304 because “rows didn’t change”, the client will keep showing totalPages: 2.
Thats wrong, because pagination UI, “last page” links, “hasNext” logic, etc. depend on metadata.
We must decide what the cached representation is.
Option A. ETag covers rows + metadata
- If either changes, ETag changes.
- You send 200 with new metadata even if rows same.
- Most correct, but fewer 304s.
Option B. ETag covers rows only
- You may return 304 when rows same.
- But metadata can be stale.
- Only OK if you accept stale metadata or you move metadata out of the cached representation.
Option C. Split it
- Cache rows with ETag.
- Compute metadata separately, or provide it via a different endpoint or header that is not cached the same way.
If JSON response includes totalCount, totalPages, hasNext, links.next, etc. and clients use it, then “Not modified” check must consider those fields too, not just the row list.
For changing datasets:
- Avoid
totalPagesentirely. Prefer cursor pagination withnextCursorandhasNext. - Then your metadata becomes stable relative to the page. No global counts that jump around.
Title
Offset pagination likes global totals, changing data hates them.