Development

Keeping Cursor Tokens Small: Compression, Encoding, Length Limits

Antonio Evans8 min read

A large cursor token string overflowing a narrow header field like water bursting a pipe, with small workers trimming the token by removing labeled blocks (signature, timestamp.
Key points

Key points

  1. 1HTTP sets no maximum header value, but common servers cap request headers near 8 KB, so a cursor that grows past a kilobyte or two will eventually trigger a 413.
  2. 2Base64 encoding adds 33 percent to payload size before you add a signature, and verbose JSON field names multiply the damage.
  3. 3Sign only a minimal payload (version byte, expiry, opaque id), then reference the rest of the state on the server by that id.
  4. 4Set an explicit byte budget per token, refuse to mint anything over it, and return a machine-readable error instead of letting a proxy reject the request.

Why cursor tokens inflate past your header budget

A cursor usually encodes the sort key of the last item on the previous page, often as a base64 blob holding a timestamp and a unique id (Mohammad Faani on cursor design). That is a sound starting point. Trouble starts when teams treat the cursor as a miniature session record and pack in filter state, sort order, tenant id, user id, permissions and a list of claims.

Three multipliers stack up. JSON repeats field names on every token: "created_at" costs twelve bytes and "2025-01-01T10:00:00Z" costs twenty, before quotes and commas. Base64 then inflates the byte string by 33 percent, and about 4 percent more if line breaks are added (Wikipedia on Base64). Signing adds a fixed 32 to 256 bytes depending on algorithm, and the signature is base64-encoded too.

Work a case. A JSON cursor at 340 bytes becomes 456 bytes after base64, and a 32-byte HMAC becomes 44 characters. The transport form is roughly 500 bytes plus the header name. Fine. Now someone adds the user's role list, the search term and the full filter object. JSON hits 1,800 bytes, base64 takes it to 2,408, the signature adds 44, and one header value is now 2,450 bytes. Send a next and a prev link together and the request carries almost 5 KB of header value before cookies, auth tokens or the user agent string.

HTTP does not police this. It defines no maximum, but Apache defaults to an 8 KB request field limit and IIS allows 16 KB, returning 413 when exceeded (Stack Overflow on header limits). Node's parser shipped with an 8 KB HTTP_MAX_HEADER_SIZE, and a long issue thread argued for 16 KB because clients were already sending large headers (Node.js issue 27645). Some commercial products expose a server.max-http-request-header-size setting and recommend starting at 8 KB, raising it only when traffic demands (Digital.ai docs). The ceiling is whatever the smallest proxy, CDN or server in your chain allows.

The decision rule: meter first, then pick a strategy

Measure before you optimize. Mint a cursor from your widest real filter set, not from a unit test, and log both the encoded length and the decoded payload length. The ratio between them tells you where the bytes go.

import base64, json

def mint(payload: dict) -> str:
    raw = json.dumps(payload, separators=(",", ":")).encode()
    token = base64.urlsafe_b64encode(raw).decode().rstrip("=")
    print(f"json_bytes={len(raw)} token_chars={len(token)}")
    return token

The rule that follows:

  • Under 1 KB: leave it alone.
  • Between 1 KB and 2 KB: tighten the payload, drop redundant fields, shorten names.
  • Over 2 KB, or growing with user input: move the payload to a server-side store and return an id plus a signature.

The 2 KB line is judgment, not specification. It leaves margin inside an 8 KB request budget so that browser cookies and a second Link header cannot tip a reasonable client over the edge. Treat 2 KB as a warning, 4 KB as a hard block, and 8 KB as the wall you will eventually be failed by.

Token sizeTypical causeStrategy
Under 1 KBVersion, timestamp, id, short signatureKeep as-is, validate at ingest
1 to 2 KBVerbose JSON or repeated claimsCompact serialization, drop fields
2 to 4 KBFilters or permissions embeddedReference server-side state by id
Over 4 KBSession-like payloadRefuse to mint, return a problem detail

Drop what does not belong in the cursor

A cursor exists to resume a query. It is not a cache for information the server does not want to look up. Anything cheaply derivable from an id belongs on the server.

Fields that routinely get stuffed in and should not:

  • The user's identity or claims. The authenticated request already names the user, and duplicating it invites disagreement between the two.
  • Tenant or workspace names. Store the id.
  • Full filter objects. If the user filtered by category, price range and date, the cursor needs to detect that the filter changed, not carry the filter. A short hash of the filter signature is enough.
  • Sort order spelled out in text. A single byte or short code covers it.
  • Display values such as titles or URLs. If it is in the cursor, the client can read it, because base64 is an encoding, not a secret.

That last point has a security consequence. Clients receive cursors, keep them in browser history, and log them. A signature proves the token was not tampered with; it does not hide the payload. Treat every byte in the token as public, which is one more reason to keep it small.

Compact serialization: binary beats JSON

When the payload must travel, encode it tightly. Three options, in order of effort:

Shortened keys. Rename created_at to t, last_id to i, filter_hash to h. A small schema map in code translates back, and a version byte lets you change the map later. This alone often cuts the JSON size by 40 percent.

Fixed binary layout. A version byte, an 8-byte big-endian timestamp, an 8-byte id and a 3-byte filter checksum is 20 bytes. Base64url of 20 bytes is 27 characters. The same information as full-key JSON runs 88 bytes and base64urls to 118 characters. The version byte also gives you a clean evolution path: an unknown version is rejected, not parsed as garbage.

CBOR or MessagePack. Integers stay integers and unwanted field names disappear, at the cost of a decoder dependency on both ends.

FormatOverhead vs rawHuman-readableTooling everywhereBest for
JSONHighYesYesPrototypes, internal APIs
Short-key JSONMediumYesYesFast fix with a schema map
CBOR / MessagePackLowNoCommon, not universalSchema-less compact payloads
Fixed binary layoutLowestNoNeeds a decoderVersioned, known cursors

A worked comparison makes the gap concrete. The JSON cursor above plus a 32-byte HMAC crosses the wire at roughly 160 characters; the binary version with the same HMAC is roughly 75. Neither is a problem yet. The difference matters once the payload carries five more fields and you are shaving bytes after an incident.

Sign only what must be signed

Signing is not free. A 32-byte digest doubles to 44 characters under base64, and asymmetric signatures run far larger. Signing also creates a key rotation question: how do you retire a key without invalidating in-flight cursors?

The practical answer is to sign only what an attacker could abuse. If the token holds an opaque id, the id is the only thing worth signing. The server looks up the row, checks ownership against the authenticated request, and rejects a mismatch. A leaked cursor from one user then cannot read another user's page even if it is edited, provided the server checks ownership after lookup.

When you do sign, sign the encoded form rather than a JSON string, so verification does not depend on field ordering. Trim base64 padding in base64url headers, because padding is legal but easily mangled by proxies and clients. Put the signing algorithm version in the same version byte as the payload so a rotation is a recognizable error rather than a silent decode failure.

Move state server-side and return an id

If the payload will not fit, stop squeezing. Mint an id, store the state with a TTL, return the id plus a signature. A random 128-bit id base64urls to 22 characters; a UUID without hyphens lands near 24. Add an HMAC and you stay under 100 characters in the worst case.

The cost is one read per page request. That read is usually what you would do anyway to validate the signature. Build it deliberately:

  • Redis or DynamoDB with a TTL, keyed by token id, storing the decoded sort key, the filter signature and a mint time.
  • An encrypted envelope if you cannot afford a read. Encryption hides contents and validates integrity, but makes tokens larger, not smaller, because ciphertext and tag both cost bytes. Use it when the payload is sensitive, not when it is large.
  • A snapshot id if ordering must stay stable while rows change. Contentful's move to cursor-based pagination pairs cursors with dropping total counts, since counting large result sets is expensive (Contentful on cursor pagination).

The trade-off is real. A cache eviction invalidates pages mid-session, so return a clear restartable error rather than a 500, and set the TTL well beyond any reasonable session.

Set explicit length limits and enforce them

A limit in a comment is not a limit. Enforce it in three places.

At mint time, compute the encoded length and refuse to sign anything over budget. Return a problem detail rather than a 500. RFC 9457 defines a machine-readable error body with type, title, status and detail, and identifies the problem class by URI so clients can branch on it (RFC 9457). The Swagger walkthrough explains why this beats ad-hoc error shapes for human and machine consumers alike (Swagger on problem details). Frameworks make it easy: Spring's ProblemDetail renders an RFC 9457 body and sets the status automatically (Spring Framework docs). The older RFC 7807 defines the same structure and is now obsoleted (RFC 7807), so target the current one.

At parse time, reject oversized input before decoding. Check length, check the version byte, verify the signature, then decode. A decoder that accepts a 10 KB string and fails on a JSON parse is a small denial-of-service surface.

In tests, generate a cursor from the widest allowed filter set and assert it stays under budget. That is the test that catches the regression when someone adds one more field.

The limit also shows up in your contract. When you return a next link in the Link header, RFC 8288 governs its serialization and relation naming (RFC 8288). A walkthrough of Link-header pagination shows how the next-page URL is carried in that header (Vincent Schmalbach on Link-header pagination). A long cursor sits directly inside that header value, so shrinking the cursor shrinks the header.

Pagination design itself affects cursor size, because page length and ordering stability decide what must travel. Guides comparing offset and keyset pagination explain why keyset avoids deep scans and returns consistent pages as rows shift (Design Gurus pagination guide). AWS documents how inefficient pagination scans rows that produce no useful results and burns metered units (AWS Database Blog). WSO2 puts both patterns side by side before you commit an API surface (WSO2 pagination design patterns). If you need a consistency guarantee across filters and sort orders, there is a detailed treatment of encoding filter signatures and tie-breakers into a signed cursor (Medium).

Versioning the token format, not the API

A cursor format is a schema, and schemas change. Put a version byte first and dispatch on it during parse. When v2 arrives, keep v1 readable for as long as old cursors can persist, then reject them with a specific problem type so clients know to restart.

This is the same discipline API versioning applies at the endpoint level. Akamai's overview covers the choices (URI, header, query parameter) and the compatibility risks of each (Akamai on API versioning). Cursors need that rigor with a different mechanism: a version byte inside the token is simpler than shunting a version through the URI, and it keeps the version tied to the format rather than the route.

Keep the parse function total, turning any input string into a valid payload or a typed error, never a crash. Length check, strict base64url decode, version dispatch, signature check, payload decode, each with its own error code. Someone debugging a 400 at 2 a.m. will remember the error code, not the prose.

For teams running several model providers behind one surface, the same discipline applies where tokens are minted and consumed. Quanta Inference exposes text, image, video, audio and embedding models through one OpenAI-compatible key, which keeps cursor logic in your own application code instead of spreading it across provider SDKs.

FAQ

FAQ

Why does base64 encoding make my cursor 33 percent bigger?

Base64 maps every three input bytes to four ASCII characters, so output is larger than input by a fixed ratio that works out to 33 percent, with about 4 percent more if line breaks are inserted, as described in the Wikipedia overview of Base64. Header values must be printable text, so this cost is unavoidable once you base64 a binary payload. Keep the binary payload small and the tax stays small.

What header limit should I design for?

The HTTP specification defines none. In practice common servers default to 8 KB per request field and some allow 16 KB, returning a 413 when exceeded, as documented on Stack Overflow. Design for a total request header budget under 8 KB, which means well under 2 KB for a single cursor if you also send a prev link.

Can I use an encrypted cursor instead of server-side state?

You can, but encryption makes tokens larger, not smaller, because ciphertext and an authentication tag both cost bytes before base64. Use encryption when the payload is sensitive, such as a filter the client should not read. Use a server-side store when the payload is merely large, because a short id absorbs that substitution cheaply.

What should the API return when a cursor is too long to mint?

Return an RFC 9457 problem detail with a 400 status, a type URI identifying the problem class, and a detail string naming the limit that was exceeded. Frameworks such as Spring render this shape natively, as shown in the Spring Framework docs. Clients can branch on type instead of parsing prose.

How do I stop a cursor from growing again after a refactor?

Add a test that mints the widest cursor your filter set allows and asserts the encoded length stays under budget. Log token length at mint time so growth shows up in telemetry. The Node.js issue about header size is a reminder that limits you do not measure are limits someone else enforces against you.

Sources

Sources

  1. Systems Design - Pagination | TIL with Mohammad
  2. Base64
  3. Maximum on HTTP header values?
  4. Increase HTTP_MAX_HEADER_SIZE to 16kb · Issue #27645
  5. Configure maximum size of the HTTP request header
  6. Why Contentful is moving to cursor-based pagination
  7. RFC 9457: Problem Details for HTTP APIs
  8. Problem Details (RFC 9457): Doing API Errors Well - Swagger
  9. Error Responses :: Spring Framework
  10. RFC 7807: Problem Details for HTTP APIs
  11. RFC 8288: Web Linking
  12. How Link-Header Pagination Works in REST APIs - Vincent Schmalbach
  13. API Pagination Guide: Cursor vs Offset vs Keyset for High ...
  14. Pagination patterns in Amazon Aurora DSQL
  15. API Pagination: Design Patterns & Best Practices
  16. Keyset + filters + sort orders without losing consistency
  17. What Is API Versioning? - Akamai
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles