Cache Key Design for Personalised Responses

Personalised responses are usually declared uncacheable, which is correct for the whole response and wasteful for the parts of it that are identical for everyone. This page covers how to split a response into cacheable and personal parts, how to design a key that shares what can be shared, and the two mistakes that turn a cache into a data-leak mechanism.

Prerequisite concepts

This assumes the key construction and Vary material in caching and response optimization and the tier model described there.

What is actually personal

Most of a personalised response is not personal In a typical product response the name, description, images, specifications and stock status are identical for every caller. Only the price after the customer's contract discount, the saved-item flag and the recommendation list vary per user. The shared part is often ninety percent of the bytes. identical for every caller — cacheable once name, description, specifications, images, category list price, availability, delivery estimate by region typically 90% of the response bytes varies per caller — not cacheable across users contract price, saved flag, recommendations a few hundred bytes, and the only reason the whole response was marked private Two requests instead of one uncacheable one The client fetches the shared resource, which is cached at the edge and served to everyone, and separately fetches a small personal overlay that is never cached. The bytes that dominate the response are served from cache while the personal fields stay private. client GET /products/8891 — cached, 60 s one entry serves every caller GET /me/products/8891 — never cached a few hundred bytes, per user client merges the two Two round trips instead of one, in exchange for serving the large half from cache — usually a clear win on any connection where the extra request is multiplexed rather than a new handshake.

The practical consequence is that “personalised” is a property of a few fields rather than of the endpoint, and the highest-value change is usually to separate them into two requests: a cacheable resource and a small personal overlay the client merges.

Designing the key when you cannot split

Where splitting is not possible, the key must include exactly the dimensions that change the response — no more.

-- Kong 3.x — a cache key from the dimensions that actually vary the response
local function cache_key(conf)
  local jwt   = kong.ctx.shared.jwt_claims or {}
  local tier  = jwt.pricing_tier or "public"      -- 4 values, not 40,000 users
  local region = kong.request.get_header("cf-ipcountry") or "xx"
  local path  = kong.request.get_path()
  local query = normalise_query(kong.request.get_raw_query())
  return ngx.md5(table.concat({ path, query, tier, region }, "|"))
end

The substitution of pricing_tier for a user identifier is the whole technique. A key including the user identifier gives each user their own copy and a hit rate near zero. A key including the pricing tier gives four buckets shared by everyone in each tier, with an identical response in each. The condition is that the tier must fully determine the response — if two users in one tier can see different prices, the key is wrong and the cache will serve one of them the other’s data.

Keying on the class rather than the individual With forty thousand users, keying on the user identifier produces forty thousand entries per resource and a hit rate close to zero. Keying on the four pricing tiers produces four entries and a hit rate above ninety percent, provided the tier fully determines the response. entries per resource, and the hit rate that follows key includes user id 40,000 entries — evicted before reuse, hit rate ≈ 0 key includes tier 4 entries — hit rate above 90% The condition is strict: the tier must fully determine the response. If any user in a tier can see something different, the key is wrong and the cache will hand one user another user's data. Prove it with a test that fetches the same resource as two users in a tier and compares the bodies byte for byte.

The two mistakes that leak data

Deriving the key from a header the caller sets. If the key includes x-pricing-tier and the client can send it, any client can read any tier’s cached response by asserting it. Derive every key component from a verified token claim, and strip the corresponding headers on the way in.

Caching a response whose Cache-Control says private. A shared cache honouring private is not optional. Where the gateway sets the header itself after transformation, make sure the caching filter runs after the header is set and reads the final value.

# Kong 3.x — cache only what is safe, and be explicit about it
plugins:
  - name: proxy-cache
    route: product-detail
    config:
      strategy: memory
      cache_ttl: 60
      response_code: [200]
      request_method: ["GET", "HEAD"]
      content_type: ["application/json"]
      vary_headers: []            # nothing from the caller varies the key
      vary_query_params: ["fields", "currency"]
      # the tier component is added by a preceding plugin from a verified claim

Decision matrix

Response shape Approach
Personalisation is a few fields split into a cacheable resource plus a personal overlay
Response varies by a small set of classes key on the class, derived from a verified claim
Response is unique per user do not cache at the shared tier; cache in the client
Personalisation is a reorder of shared items cache the items, personalise the order client-side
Contains anything confidential to one user no shared cache, no exceptions

Gotchas and failure signals

A hit rate that is high but the wrong content is the failure this design risks, and it does not appear on any dashboard. Test with two users in the same class and compare responses byte for byte in CI.

Tier membership changes and the cache does not know. Keep the tier’s cache lifetime short enough that a customer upgraded this morning sees the new price today, or invalidate on the tier change event.

A cached 401 or 403 is a denial-of-service against one user. Restrict the cacheable status codes to those you intend, as in the configuration above.

Vary on a header you also key on doubles the dimension and halves the hit rate for no benefit.

Validation

  • Every key component derives from a verified claim, never from a caller-set header
  • Two users in the same class receive byte-identical responses, asserted in CI
  • Only 200 and 404 are cacheable, and only for safe methods
  • Class lifetime short enough that a membership change is visible the same day
  • private responses verified not to enter the shared tier
  • Hit rate and entry count monitored per route, so a key change is visible

FAQ

Can a personalised response be cached at all?

Usually only in parts. In a typical response the personal fields are a few hundred bytes and everything else — names, descriptions, images, specifications, list prices — is identical for every caller. Splitting into a cacheable resource plus a small personal overlay that the client merges recovers almost all the benefit, and is a bigger win than any key-design trick.

How do I key a response that genuinely varies per user?

Key on the class that determines the response rather than on the user. If four pricing tiers fully determine the price, the key needs the tier and not the user identifier: four entries instead of forty thousand, and a hit rate above ninety percent instead of near zero. The condition is strict — if two users in one tier can see different content, the key is wrong.

What is the most dangerous mistake here?

Deriving a key component from a header the caller can set. If the key includes a pricing-tier header that clients send, any client can read another tier’s cached response by asserting it. Every key component must come from a verified token claim, and the corresponding inbound headers must be stripped at the edge.

How do I prove the cache is not serving the wrong user’s data?

With a test rather than a dashboard, because a hit rate cannot tell you the content was right. Fetch the same resource as two different users in the same class and compare the bodies byte for byte in CI. Run the same test for two users in different classes and assert the bodies differ.


Parent: Caching & Response Optimization