Cache Headers and Payload Shapes Are Part of Your API Contract
Jul 2026 ยท 8 min read
List endpoints on the property platform I architected โ properties, city areas, user ads โ got slow on mobile as the data grew, and expensive to parse well before that: every list response carried the same fields as a detail response, full descriptions and every image included, for a UI that was only ever going to render a card with a title, a price, and a thumbnail. The fix wasn't "add caching." It was recognizing that payload shape and cache policy are two separate levers, and the bigger win was in the first one.
Shrink what you send before you decide how long to cache it
Caching an endpoint that returns more data than the UI needs just means efficiently re-serving waste. The real fix came first: a responseMode=card query parameter that returns a stripped-down payload for list views โ no long description field, images capped to one via imageLimit=1, and only that one image actually presigned, instead of presigning every image in a listing just to render a single thumbnail. The full detail payload is still there, at GET /properties/:id, for the one place that actually needs it.
This is the same resource with two projections, selected by the client's stated intent, rather than either sending the maximal payload everywhere or standing up a second, parallel "lite" endpoint that someone has to remember to keep in sync with the real one every time a field changes.
Cache duration is a question about the data, not a house style
Once the payload was right-sized, cache duration got tuned per endpoint against how that specific data actually changes โ not a single blanket policy applied everywhere for consistency's sake:
GET /properties Cache-Control: public, max-age=60, stale-while-revalidate=300
GET /properties/filters Cache-Control: public, max-age=3600, stale-while-revalidate=86400
GET /cities/:id/areas Cache-Control: public, max-age=300, stale-while-revalidate=1800Property listings get a 60-second window because listings genuinely change on that timescale โ new ones get added, prices get edited, availability flips. The set of valid filter options (which cities exist, which property types are selectable) changes on a completely different, much slower cadence, so it gets a full hour. Applying the properties list's 60-second policy to the filters endpoint would just mean re-fetching a payload that's almost never actually different โ correct, but wasteful for no benefit. Applying the filters endpoint's one-hour policy to property listings would mean serving stale prices and availability for far longer than the product can tolerate. The right number comes from the data's actual volatility, not from picking one value and reusing it everywhere for the sake of a tidy config file.
What stale-while-revalidate actually buys you
stale-while-revalidate means a client can be served a cached response immediately, even after max-agehas technically passed, while a fresh copy is fetched in the background for the request after that. That reframes what the cache duration number really means: it's not "how stale are we willing to let this get" in an absolute sense, it's "how long before we bother kicking off a background refresh," since no single request is ever actually blocked on freshness. Once you see it that way, a short max-age with a generous SWR window is usually a better default than a longer max-age alone โ you get to refresh more eagerly without making any real request pay a latency cost for it.
The moment a cached response becomes the wrong response
Flatmate and user-ad list responses embed each item's connection status directly in the list payload โ whether the requesting user already expressed interest, whether there's a pending request, what the retry cooldown looks like โ deliberately, so a card doesn't need a second round trip just to know what button to show. That's a genuinely good payload design choice, and it creates exactly one hazard: the moment a response is personalized to the requesting user, Cache-Control: publicstops being a caching optimization and becomes a data leak waiting to happen. A publicly cached response that embeds one user's connection state can get served straight back to a different user or an anonymous visitor.
The fix is switching to Cache-Control: private, max-age=0, no-storespecifically when personalization is present in that particular response, rather than deciding the whole endpoint is either always cacheable or never cacheable. The same endpoint serves a public, cacheable payload for an anonymous request and a private, uncached one the moment a specific user's data enters the response โ the caching rule follows the actual content of the response, not a fixed decision made once about the route.
Writing it down is the actual deliverable
None of these individual choices โ a query param for payload shape, a shorter max-age for volatile data, a public/private split for personalized responses โ is exotic on its own. What made this valuable as a system rather than a pile of one-off tuning decisions was writing the reasoning down per endpoint, in one place: this list needs this shape because of this client, this cache window exists because this data changes on this timescale, this response goes private the moment personalization enters it. Caching and payload decisions are usually made in isolation, by whoever's touching performance that particular week, and they compose into a real contract about what a client can assume is present and how fresh it's allowed to assume that data is. Writing that contract down explicitly is what turns it from tribal knowledge one engineer holds into something the next engineer can read, trust, and extend consistently instead of re-deriving from scratch.