HTTP caching
Summary. A read that many callers ask the same way can be answered without reaching the
application: by the browser of the caller, or by a shared cache in front of the API (Varnish, a
CDN). The answer itself says who may keep it and for how long, in Cache-Control; the kit has a
route declare that once, refuses a declaration that would leak the answer of one caller to
another, and answers no-store for every authenticated route that declares nothing. This page
says what to cache at which layer, and the one thing never to cache: a decision of
authorization made for one actor.
Three layers, three questions
Section titled “Three layers, three questions”| Layer | Keeps | Lives | Invalidated by | In the running example |
|---|---|---|---|---|
| HTTP cache | whole answers, keyed by URL (and what Vary names) |
the browser, Varnish, a CDN: outside the process | expiry (max-age), revalidation (ETag), a purge |
the opening hours of a calendar, read by every patient of the practice |
Application cache (Cache port) |
values the application computed or fetched | the process, or a Redis protocol server | expiry, a deletion by key | the working hours a gateway read from another context |
| Read model | rows shaped for a query, kept up to date by events | the database | the events of the aggregates it follows | the agenda of a practice, searched by day |
They answer different questions. A read model answers "what is the state, shaped for this question?" and is correct by construction, eventually. An application cache answers "have I computed this recently?" inside the process (cache and coordination). An HTTP cache answers "has anybody already asked this exact URL?", and it is the only one that saves the request itself: no connection to the application, no authentication, no query.
Choose in that order: a slow query is fixed by an index or a read model first; an expensive computation inside a use case by the application cache; and only an answer that is the same for many callers, read far more often than it changes, is worth an HTTP cache in front of it.
What may be kept by whom
Section titled “What may be kept by whom”Cache-Control names the caches that may keep an answer:
public: every cache on the way, shared ones included. A shared cache keys an answer by its URL and hands it to the next caller who asks the same URL.private: the browser of the caller only. Useful for an authenticated read the same person asks again (the agenda of the day), together with anETagso that asking again costs a304instead of a body.no-store: nobody. The default of the kit for every route that knows its caller.
The freshness directives say for how long: max-age for every cache, s-maxage for shared
caches, stale-while-revalidate and stale-if-error for how long a stale answer may still be
served while the cache asks again or while the server fails.
What the kit answers for the three kinds of read of the running example, the directives always in this order:
# GET /calendars/c-1/opening-hours: the same for everybody (scope public)Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=30, stale-if-error=86400
# GET /calendars/c-1/days/2026-03-02: what this caller may see (scope private)Cache-Control: private, max-age=0, must-revalidate
# a route that knows its caller and declares no policy; every refusal of a route that has oneCache-Control: no-storeNever cache a decision of authorization across actors
Section titled “Never cache a decision of authorization across actors”What a caller may see is decided by the use case, for that caller: its permission, its access scope, its tenant (authentication and authorization). An answer that went through that decision is the answer for that actor. Kept by a shared cache under its URL alone, it is served to the next actor without the decision being made again: the patient of one practice reads the agenda of another, a revoked delegation still reads what it read yesterday.
Hence the rule the kit holds: a public answer of a route that knows its caller names in
vary what says who calls, or its tenant. Declared without any vary, the route is refused
when it is declared (defineRoute, or controllerRoutes for a controller); mounted with a vary that names neither Authorization, Cookie nor a
header the tenant is read from (vary: ['Accept-Language'] alone), it is refused when the routes
are mounted, where the options of the application are known. With vary: ['Authorization'] a
shared cache keeps one answer per credential, which is rarely worth it; private is the usual
answer. A route open to anybody (Public, security: false) shows the same thing to everybody
and may be public as it is, unless its answer reserves fields to some callers
(withFieldAccess with a read requirement, fields reserved to some callers):
the pipeline still knows who calls and leaves those fields out per caller, so such an answer is
refused at the mount unless its vary names Authorization or Cookie.
import { Authenticated, CacheScope, RouteGroup } from '@ddd-ts-kit/http';
// instead of: a public answer behind Authenticated, with no vary. Every read of the group is// refused with a DeclarationError when its routes are built, and the ESLint rule// presentation/shared-cache-varies reports it in the editor.@RouteGroup({ prefix: '/agenda', guards: [Authenticated], cache: { scope: CacheScope.public, maxAge: '1m', sMaxAge: '5m' },})export class SharedAgendaRoutes {}
// write: kept by the browser of the caller only, and revalidated with its ETag@RouteGroup({ prefix: '/agenda', guards: [Authenticated], cache: { scope: CacheScope.private, maxAge: '0s', mustRevalidate: true, etag: 'representation' },})export class AgendaRoutes {}Vary holds only where the cache honours it. Varnish and Fastly keep one copy per value of
the headers an answer varies on. Cloudflare ignores Vary (images aside) and Bunny keys on a
fixed set of its own: behind them, vary: ['Authorization'] hands the answer of one actor to the
next. The kit knows the cache from the tag header the application names (cacheTags.header), and
refuses at the mount a public answer that varies behind a cache that ignores Vary, unless the
application says it made the cache key on those headers (cacheTags.honoursVary). An answer
chosen by Accept among JSON, XML and CSV varies on Accept: behind such a cache, it would
hand the XML of one caller to the next asking JSON, and is refused the same way.
The tenant is a dimension too. A request whose tenant is read from a header
(tenantFromHeader) shows that tenant's data under the same URL as another's: every answer a
cache may keep varies on the header, added by the pipeline from the resolver
(TenantResolver.headers). A tenant read from the host or the path needs nothing: it is part of
what every cache keys on.
Two subtler leaks the declaration cannot see, and a reviewer must:
- A public route whose answer depends on something else than its URL: a language read from
Accept-Language, a header the code of the project reads itself. Name it invary. The headers the tenant resolver reads are added for you, and the host is part of every cache key. - A shared answer that embeds the result of a permission: a public list that leaves out what "the anonymous caller" may not see is fine, since every caller of it is anonymous; a list whose content changes with the token is not public.
Revalidation instead of a new answer
Section titled “Revalidation instead of a new answer”A cache that holds a stale answer, or a browser holding a private one, does not have to fetch
it again: it asks whether its version is still the current one. The answer names its version
in ETag (or the instant it last changed in Last-Modified), the caller sends it back in
If-None-Match (or If-Modified-Since), and the server answers 304 Not Modified without a
body when nothing changed.
GET /calendars/c-1/opening-hours
HTTP/1.1 200 OKCache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=30, stale-if-error=86400ETag: W/"7"
{ "calendarId": "c-1", "version": 7, "ranges": [ ... ] }
GET /calendars/c-1/opening-hoursIf-None-Match: W/"7"
HTTP/1.1 304 Not ModifiedETag: W/"7"Two ways to name a version, two costs:
| Tag | Made from | Costs the server | Fits |
|---|---|---|---|
| strong, from the representation | a hash of the bytes answered | the whole read, then the hash: only the transfer is saved | any read, without knowing anything of the model |
| weak, from a version | the version of the aggregate or of the read model the answer shows | the read, which could be skipped by a handler that checks the version first | a read of one element whose version is in the body |
The same version for a write: If-Match
Section titled “The same version for a write: If-Match”A version given out with a read can come back with a write: If-Match: W/"7" says "change it
only if it is still version 7". The use case compares that version with the one of the
aggregate it loaded and refuses with 412 Precondition Failed when somebody else changed it in
between. This is optimistic concurrency stretched to the person: the version they saw on the
screen, minutes ago, and not only the one the handler loaded a millisecond before saving, which
the version column of the aggregate already protects. The decision stays in the use case,
which knows the aggregate; the edge only reads the header and hands the version over.
PUT /calendars/c-1/opening-hoursIf-Match: W/"7"
HTTP/1.1 412 Precondition FailedContent-Type: application/problem+jsonCache-Control: no-store
{ "type": "urn:problem:precondition-failed", "title": "Precondition Failed", "status": 412, "code": "PRECONDITION_FAILED", "detail": "c-1 is at version 8, and the change was made on version 7.", ... }The controller declares ifMatch and passes request.context.expectedVersion into its
command; the handler compares it with the version it loaded:
import { definePermissions } from '@ddd-ts-kit/auth';import { AggregateRoot, AuthorizedCommand, Clock, ConceptError, expectVersion, type Actor,} from '@ddd-ts-kit/kernel';import { CommandHandler, Requires } from '@ddd-ts-kit/kernel/declarations';
// the aggregate, its repository port and its error, from the domaindeclare class Calendar extends AggregateRoot { setOpeningHours(ranges: readonly string[], at: Date): void;}declare abstract class CalendarRepository { findById(id: string): Promise<Calendar | undefined>; save(calendar: Calendar): Promise<void>;}declare class CalendarError extends ConceptError { static unknown(calendarId: string): CalendarError;}
const permissions = definePermissions('scheduling', { 'calendar.set-opening-hours': { description: 'Set opening hours' } });
@Requires(permissions['calendar.set-opening-hours'], { resource: (command: SetOpeningHoursCommand) => ({ type: 'calendar', id: command.calendarId }),})export class SetOpeningHoursCommand extends AuthorizedCommand<string> { constructor( actor: Actor, readonly calendarId: string, readonly ranges: readonly string[], /** The version the client read, from If-Match; undefined when it sent none. */ readonly expectedVersion?: number, ) { super(actor); }}
@CommandHandler(SetOpeningHoursCommand)export class SetOpeningHoursHandler implements CommandHandler<SetOpeningHoursCommand> { constructor( private readonly calendars: CalendarRepository, private readonly clock: Clock, ) {}
async execute(command: SetOpeningHoursCommand): Promise<string> { const calendar = await this.calendars.findById(command.calendarId);
if (calendar === undefined) { throw CalendarError.unknown(command.calendarId); }
// PreconditionFailedError, answered 412, when somebody changed the calendar since that read expectVersion(calendar, command.expectedVersion); calendar.setOpeningHours(command.ranges, this.clock.now()); await this.calendars.save(calendar);
return calendar.id; }}Invalidation: purge after the commit, never before
Section titled “Invalidation: purge after the commit, never before”An answer kept for an hour is wrong for up to an hour after the change it shows, unless the
change purges it. The kit purges by tag: an answer names the resources it shows
(calendar:c-1), the shared cache files it under them, and the command that changes the
calendar asks for that tag to be purged.
When matters more than how:
- Before the commit is wrong twice: a rollback leaves the cache emptied for nothing, and
worse, a reader arriving between the purge and the commit refills the cache with the old
state, which then stays for the whole
s-maxage. - After the commit, in the process (an
after-commithandler) is right until the process stops between the commit and the purge: the change is saved, the cache never hears of it. - After the commit, through the outbox is what the kit does. The use case asks
CacheInvalidationfrom abefore-commithandler of the domain event; the request is a row of the outbox, written in the transaction of the command. A rollback removes it; a commit keeps it until the relay, in this process or the next one, hands it to theCachePurger, retrying while the cache does not answer.
In code, the purge is one line in a handler of the domain event that changed what the answers show:
import { CacheInvalidation, CachePurger, cacheTag, type DomainEvent } from '@ddd-ts-kit/kernel';import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';
export class CalendarOpeningHoursSet implements DomainEvent { static readonly eventName = 'scheduling.calendar.opening-hours-set';
readonly name = CalendarOpeningHoursSet.eventName;
constructor( readonly aggregateId: string, readonly occurredAt: Date, ) {}}
// instead of: a purge sent after the commit, from the process: lost if it stops in between@DomainEventHandler(CalendarOpeningHoursSet, { phase: 'after-commit' })export class PurgeOpeningHoursAtOnce implements DomainEventHandler<CalendarOpeningHoursSet> { constructor(private readonly purger: CachePurger) {}
async handle(event: CalendarOpeningHoursSet): Promise<void> { await this.purger.purge([cacheTag('calendar', event.aggregateId)]); }}
// write: before-commit (the default phase), the request is a row of the outbox, in the// transaction of the command: a rollback purges nothing, a commit purges even after a crash@DomainEventHandler(CalendarOpeningHoursSet)export class CalendarOpeningHoursSetEventHandler implements DomainEventHandler<CalendarOpeningHoursSet> { constructor(private readonly cacheInvalidation: CacheInvalidation) {}
async handle(event: CalendarOpeningHoursSet): Promise<void> { await this.cacheInvalidation.invalidate([cacheTag('calendar', event.aggregateId)]); }}The composition root gives the relay a publisher that hands the purge requests to the cache and every other event to the broker:
// platform: the publisher of the outbox relayimport { VarnishCachePurger } from '@ddd-ts-kit/http/purgers';import { PurgingPublisher, RepeatedCachePurger, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
declare const broker: IntegrationEventPublisher;declare const config: { readonly varnishUrl: string; readonly varnishPurgeKey: string };
export const publisher = new PurgingPublisher( // purged again two seconds later: see "A read on its way when the purge runs" below new RepeatedCachePurger(new VarnishCachePurger({ url: config.varnishUrl, key: config.varnishPurgeKey }), { againAfterMs: 2_000, }), broker,);A read model updated by a projection in the same transaction is purged by the same request; a read model updated later, by a consumer, asks for its own purge in the transaction of its consumer, so that the purge never comes before the row it would refetch.
sequenceDiagram participant C as Command participant DB as Database (state + outbox) participant R as Outbox relay participant V as Varnish C->>DB: save the calendar, append the purge request (one transaction) DB-->>C: commit Note over C: the process may stop here R->>DB: claim the purge request R->>V: PURGE xkey-purge: calendar:c-1 V-->>R: 200 R->>DB: mark it published
A multi-tenant application files the tags under the tenant (acme/calendar:c-1) on both sides,
the answers (cacheTags.perTenant) and the purges (perTenant of the invalidation), so a purge
never reaches the answers of another tenant. Both sides refuse what would never meet: a tagged
public route anybody may call is refused at the mount when nothing resolves the tenant of an
anonymous call (resolveTenant), a tagged answer to a request that names no tenant is sent
no-store, and an invalidation asked by work that names no tenant (a job, a system actor) throws
NoCurrentTenantError. A project generated multi-tenant behind Varnish reads the tenant of a
public call from x-tenant-id (tenantFromHeader() in its HTTP boundary).
A read on its way when the purge runs
Section titled “A read on its way when the purge runs”The outbox closes the window between the commit and the purge; one more remains, the other way
round. A public read that loaded the previous state just before the commit can reach the cache
after the relay purged: it is then kept as fresh, for its whole s-maxage. The relay is
fast (woken at once when the command commits), so the window is the time of one read, but an hour
of s-maxage turns it into an hour of a stale answer. Three ways to close it, from the cheapest:
- Purge twice:
RepeatedCachePurgerpurges, then purges the same tags again a little later (againAfterMs, longer than the slowest read). The second purge removes what arrived in between; it is best effort, lost if the process stops before it, which the first one is not. The running example does this, two seconds apart. - A short
s-maxageon a route that must converge fast, withstale-while-revalidateto keep the cost of the misses low: the window can last no longer than the freshness. - A soft purge with a short grace (
soft: trueofVarnishCachePurger,Fastly-Soft-Purge): the purged answer is served stale while the cache fetches the new one, so purging often costs little; it does not by itself remove an answer that arrives after the purge.
How the kit declares it
Section titled “How the kit declares it”The policy is part of the declaration of the route, so the server and the OpenAPI contract say
the same thing: on a controller, on its route group (every read of the group, GET and HEAD:
a write of a cached group keeps the default of a write), or in the spec of a function route. The fields and the refusals are listed in the
@ddd-ts-kit/http README.
Validators (etag, lastModified) belong to the same policy, and ifMatch to the declaration
of a write.
import { CacheScope, Public, RouteGroup } from '@ddd-ts-kit/http';
@RouteGroup({ prefix: '/practices', guards: [Public], cache: { scope: CacheScope.public, maxAge: '1m', sMaxAge: '5m', staleWhileRevalidate: '30s' },})export class PracticeDirectoryRoutes {}The read of the running example puts every piece of this page together on one controller: a public answer, kept an hour by the shared cache, revalidated by the version it shows, and filed under the tag of its calendar so that the command that changes the hours purges it.
import { z } from 'zod';import { CacheScope, Controller, Public, type HttpRequest } from '@ddd-ts-kit/http';import { Query, type QueryBus } from '@ddd-ts-kit/kernel';import type { Infer } from '@ddd-ts-kit/validation';import { zodSchema } from '@ddd-ts-kit/validation/zod';
const params = zodSchema(z.object({ calendarId: z.string() }));const answer = zodSchema( z.object({ calendarId: z.string(), version: z.number(), ranges: z.array(z.object({ weekday: z.string(), start: z.string(), end: z.string() })), }), { io: 'output' },);
// the query of the use case, declared @Public() in the application layerdeclare class GetOpeningHoursQuery extends Query<Infer<typeof answer>> { constructor(calendarId: string);}
@Controller({ tags: ['scheduling'], guards: [Public], method: 'GET', path: '/calendars/:calendarId/opening-hours', summary: 'Get opening hours', params, response: { status: 200, description: 'The weekly hours of the calendar.', body: answer }, cache: { scope: CacheScope.public, // the same for everybody: Public, no vary needed maxAge: '1m', // the browser asks again after a minute sMaxAge: '1h', // Varnish keeps it an hour, unless a purge comes first staleWhileRevalidate: '30s', staleIfError: '1d', // served stale for a day if the API fails etag: { version: 'version' }, // W/"7" from the version field of the body tags: [{ resource: 'calendar', param: 'calendarId' }], // filed under calendar:c-1 },})export class GetOpeningHoursController { constructor(private readonly queries: QueryBus) {}
async handle(request: HttpRequest<{ params: Infer<typeof params> }>): Promise<Infer<typeof answer>> { return this.queries.execute(new GetOpeningHoursQuery(request.params.calendarId)); }}Limits
Section titled “Limits”- A purge comes after the relay: between the commit and the purge, a shared cache serves the previous answer, for as long as the relay takes (its interval, or at once when it is woken). A read that must show the write it follows is not served from a shared cache.
- A strong
ETagsaves the transfer, not the work: the handler runs, then the answer is hashed. A weakETagfrom a version lets a handler that checks the version first skip the read; the kit does not do it for you. vary: ['Authorization']keeps one answer per credential: as many entries as callers, each purged only by its tags. It is correct and rarely worth it.- The cache in front decides what it honours: the VCL the CLI writes passes every request with
Authorizationor a cookie, whatever the policy says, and keeps nothing an answer does not allow by name (max-ageors-maxage); a CDN may ignorestale-if-error, and Cloudflare and Bunny ignoreVary. - A weak
ETagnames a version, not a representation: two actors who see different fields of the same version get the same tag. On a route whose answer depends on the actor, a browser shared by both could answer304with the copy of the previous one: useetag: 'representation'there. If-Matchis read before the guards of a controller: a malformed one is answered412, or a missing required one428, before the guards answer401or403. It names one version: a list of tags is refused412. A write that declares noifMatchignores the header.- The refusals are
no-store: every problem answered by a route that has a policy or knows its caller saysCache-Control: no-store; the contract documents the header on the success answers only. The tag header (xkey,surrogate-key) is documented as thex-cache-tagsextension.
See also
Section titled “See also”- Resilience: a cache as a stability tool, and its failure modes
- HTTP caches: Varnish, Souin, nginx and the CDNs compared, with their limits
- Cache and coordination: the application cache on Redis, Valkey or Dragonfly
- Transactional outbox: the row that makes the purge survive a crash
- The presentation layer: controllers, route groups and guards
- CQRS: read models