The question we started with
“Why is our admin API caching responses that include user PII?” That’s the query that kicked off our performance tuning project. We had a typical layered architecture: browser, CDN, load balancer, application, Redis, database. And somewhere in that stack, a shared cache was holding onto responses it had no business storing. We needed to fix it fast, and we learned a few things about cache headers that we wish we’d known on day one.
Step 1: Know your data and set the right Cache-Control
Before you tune anything, you have to classify your data. For our admin API, every response contained sensitive user data. That meant the default CDN behavior—which, for instance, caches 200 responses for 120 minutes when no cache-control headers are present (Cloudflare, default cache behavior)—was a non-starter. We had to explicitly forbid caching.
The right directive was Cache-Control: no-store. That forbids any cache, private or shared, from storing the response (MDN, Cache-Control). It’s the bluntest instrument, and that’s exactly what we needed. But we also had to make sure our CDN actually respected it. Cloudflare, for one, will not cache a response if it sees no-store, but only if you’ve set it correctly. We tested by hitting an endpoint, checking the cf-cache-status header, and confirming it said DYNAMIC.
For our public marketing site, we took the opposite approach: we set Cache-Control: public, max-age=604800 on static assets. That keeps a response fresh for seven days (MDN, Cache-Control). The difference is stark: one endpoint is uncacheable by design, the other is cacheable for a week. You can’t tune performance until you’ve made that call.
Step 2: Don’t forget the load balancer layer
Our load balancer (we run Nginx in front of our application servers) was also caching responses by default, because we had configured a proxy_cache_path. That’s a file-based cache where the file name is the MD5 of the cache key (Nginx, proxy module). We had to make sure that cache also respected our no-store directive. Nginx’s proxy cache will skip caching if the response includes no-store in the Cache-Control header, but only if you’ve set proxy_cache_valid correctly or used proxy_no_cache with a condition. We ended up adding a proxy_no_cache rule that checked for the presence of no-store.
Another thing we learned: even if you’re not caching at the load balancer, you should still set the header correctly for downstream caches. A CDN sits in front of your load balancer, and if you don’t set no-store, the CDN might cache something you never intended.
Step 3: Revalidation is your friend for semi-dynamic data
For a different endpoint—say, a user’s profile page that changes infrequently but is still private—we could have used no-cache instead of no-store. That allows a cache to store the response but requires revalidation with the origin before each reuse (MDN, Cache-Control). That would have given us some performance benefit, but we decided it wasn’t worth the risk: if revalidation fails, you might serve stale PII. So we stuck with no-store for anything sensitive.
For public, rapidly changing data, we used stale-while-revalidate to smooth out traffic spikes. This extension, defined in RFC 5861, lets a cache serve a stale response for up to the indicated seconds while revalidating in the background (RFC 5861). We set Cache-Control: public, max-age=60, stale-while-revalidate=300 on our product catalog. That way, if the origin is slow, users still get a response—just possibly a tiny bit stale—and the cache is updated in the background. It’s a great tool for performance, but it’s not for sensitive data.
Step 4: Watch out for Vary and cache poisoning
One of the trickiest parts of HTTP caching is the Vary header. It tells caches which request headers influenced the response, so they store a separate copy for each value (MDN, Vary). We had an endpoint that returned different JSON depending on the Accept-Language header. Without a Vary: Accept-Language, a cache could serve an English response to a French user. But adding Vary also fragments your cache, which can hurt hit rates.
We also saw a case where a CDN was ignoring Vary and serving the wrong variant. The fix was to ensure the Vary header was set on the origin response and that our CDN respected it. If you’re using a CDN, test this: make a request with a different header and see if you get the right variant. If not, you may need to configure the CDN to treat the URL as uncacheable or to include the header in the cache key.
Another gotcha: if you ever change the Vary header, you must purge the cache for that URL. We learned that the hard way when we added Vary: Accept-Language and didn’t purge. Users got wrong-language responses for hours. Cloudflare offers single-file purge that instantly removes a cached resource across all data centers (Cloudflare, purge by single-file). We now use that after any header change.
Step 5: When you do cache, make it deliberate
We’re not anti-caching. For our public marketing site, we cache aggressively. But we’ve learned that default caching is dangerous. The default behavior of a CDN, when no cache-control headers are present, is to cache certain status codes for set times: 200s for 120 minutes, 302s for 20 minutes, and 404s for 3 minutes (Cloudflare, default cache behavior). That’s fine for static assets, but it’s a disaster for an API that returns dynamic data. So we now set explicit Cache-Control headers on every response, even if it’s just no-store.
We also set Cache-Control: private on responses that are user-specific but not sensitive—for example, a user’s preferences. That allows a browser cache but forbids shared caches like a CDN (MDN, Cache-Control). It’s a good middle ground for data that isn’t secret but shouldn’t be shared across users.
What I’d actually do
If you’re running an API that serves any private or user-specific data, set Cache-Control: no-store on those endpoints by default. Don’t rely on the CDN’s default behavior. Add a Vary header for any request header that changes the response, and test that your CDN respects it. For public, cacheable content, be explicit: use public, max-age and consider stale-while-revalidate for high-traffic, slightly dynamic resources. And remember: cache invalidation is not a magic bullet—if you set the right headers from the start, you’ll rarely need to purge.
Our admin API now returns no-store for every response, and our CDN hit ratio for that zone dropped to near zero. That’s fine—it’s not supposed to be cached. Our public site still gets 80-95% hit ratios (AWS, caching overview) because we’ve set proper Cache-Control headers. The key is to be deliberate about what you cache and what you don’t. Your cache headers are your contract with the entire network—write them carefully.
Sources
- MDN (Cache-Control) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- RFC 5861 (stale-while-revalidate) - https://www.rfc-editor.org/rfc/rfc5861.txt
- MDN (Vary) - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Vary
- Cloudflare (default cache behavior) - https://developers.cloudflare.com/cache/concepts/default-cache-behavior/
- Cloudflare (purge by single-file) - https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/
- AWS (caching overview) - https://aws.amazon.com/caching/
Comments (0)
Please sign in to post a comment.
Don't have an account? Create one
No comments yet. Be the first to comment!