On this page
API caching has three separate jobs: avoiding requests while a representation is fresh, avoiding body transfers when a representation is unchanged, and avoiding expensive backend work. Cache-Control, ETags, and Redis address different parts of that problem. Combining them safely requires clear ownership of freshness and user-specific data.
Start with a response classification
Use the API latency tester to record a baseline for a non-sensitive read endpoint before adding caching. Compare status and timing across repeated requests, but inspect response headers separately in your HTTP client. Faster responses alone do not establish whether a browser cache, intermediary, or Redis served the result.
Classify each route as public, user-specific, or too sensitive to retain. A public product catalog might tolerate a short freshness window. An account balance needs an explicit consistency decision. A token exchange should generally avoid storage by HTTP caches. Write the policy alongside the endpoint contract.
Define a staleness budget in business terms. Ask how long a removed item may remain visible, whether a newly saved setting must appear immediately, and which mutations affect each representation. Do not choose a five-minute lifetime merely because another service uses one.
Also define the source of truth. Redis should not quietly become the authoritative store for data that originally belonged in a transactional database. Cache loss must be recoverable, and eviction must not erase business records.
Express freshness and privacy with Cache-Control
Cache-Control tells HTTP caches how a response may be stored and reused. A public catalog response might use public, max-age=30, s-maxage=120: a browser can reuse it for thirty seconds, while a shared cache receives a separate two-minute freshness lifetime. Apply this only when every requester may receive the same representation.
For user-specific data, private, no-cache permits a private cache to store the response but requires validation before reuse. No-cache is not no-store. Use no-store when the policy is that caches must not retain the response, particularly for sensitive authentication material. Review the full semantics in the MDN Cache-Control reference.
Include representation dimensions in the cache policy. If language selection depends on Accept-Language, a cache must distinguish those variants, often with Vary. Avoid adding every request header indiscriminately: high-cardinality keys can destroy reuse. Never treat Vary: Cookie as a substitute for deciding whether shared storage is safe.
Authenticated requests require particular care because shared-cache rules interact with authorization and response directives. Prefer an explicit private policy unless you have designed and tested a public representation. A single cross-user cache leak can outweigh every performance improvement.
Use ETags for conditional validation
An ETag identifies a selected representation. When the client later sends If-None-Match, the server can return a bodyless 304 Not Modified if the current representation matches. This saves transfer, but it saves database work only if the validator can be checked cheaply.
A framework-neutral Node-style example can validate serialized public data. The handler assumes a public catalog and a complete representation in memory:
import { createHash } from "node:crypto";
function sendCatalog(req, res, catalog) {
const body = JSON.stringify(catalog);
const digest = createHash("sha256").update(body).digest("hex");
const etag = 'W/"' + digest + '"';
res.setHeader("ETag", etag);
res.setHeader("Cache-Control", "public, max-age=30");
// Deliberately handles only a single exact validator.
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
return res.end();
}
res.setHeader("Content-Type", "application/json");
res.end(body);
}Production conditional-request handling should use a tested parser or framework support: If-None-Match can contain multiple validators or a wildcard, and its comparison rules are not simple string equality. Preserve relevant cache metadata on the 304 response. See the MDN If-None-Match reference for those details.
The example uses a weak validator, suitable for cache validation rather than byte-level range guarantees. If using a database revision instead, ensure it changes whenever any field in the response changes. ETags are validators, not secret tokens or authorization controls.
Add Redis for expensive application work
Cache-aside means the application checks Redis first, queries its source on a miss, then stores the result with a bounded lifetime. This can reduce database work even when the HTTP response itself is private or requires revalidation. Consult the Redis caching documentation for the broader pattern.
Build keys from every input that affects the cached value: tenant, resource identifier, locale, filters, and representation schema. Cache public data separately from user-specific projections. Authenticate and authorize before returning a cached object; a cache hit must never bypass access checks.
Use bounded TTLs and small randomized expiry offsets to reduce synchronized misses. For popular expensive keys, use a short-lived coordination mechanism or request coalescing so that one miss does not trigger hundreds of database queries. If Redis is unavailable, decide whether the endpoint can fall back safely without overwhelming the database.
Cache empty results briefly only when the semantics are safe. An absent product may soon be created, and a permission-denied response must not become a shared negative entry. Monitor memory, eviction, serialization failures, and hit rates alongside latency. A high hit ratio is not success if cached values are wrong.
Design invalidation and consistency explicitly
After a successful mutation, invalidate affected keys or publish new versioned keys. Update the authoritative database before announcing a new cached representation. Recognize the race: an older in-flight read can repopulate stale data after deletion. Strict requirements may need revision checks, coordinated writes, or another consistency mechanism rather than simple delete-on-write.
Redis expiry and HTTP freshness operate independently. Deleting a Redis key does not recall a response already considered fresh by a browser. Set HTTP lifetimes to match the real business budget and provide a deliberate read-after-write strategy, such as returning the updated representation directly from the mutation.
Test cache isolation with two users and two tenants, then test expiry, failed invalidation, and Redis downtime. Use a JSON formatter to compare expected representations during debugging. Record the complete policy in your backend API guides, including which layer owns each lifetime and how operators disable caching during an incident.
Key takeaways
- Separate HTTP freshness, conditional validation, and application data caching.
- Treat privacy classification as a prerequisite for cache reuse.
- Make validators cover the entire selected representation.
- Include tenant and representation dimensions in Redis keys.
- Test stale-read races and remember that Redis invalidation cannot purge browser freshness.
Reach designers & developers at work
Put your product next to the tools people use every day. One sponsor per placement, no tracking pixels.
Become a sponsor