Skip to content

ADR: Cache headers

ADR: Cache headers

Status
accepted
Date
2026-09-02
Shipped as
1.0.0.20

In plain words

Browsers keep copies of files so pages load faster. This decides what they may keep: the code files, whose names change whenever their contents change, are kept for a year, and the page itself is checked with the server on every visit, so a new version reaches every visitor the next time they load the site.

What that is worth: users get fast pages and never a stale mix of old and new, and the organization ships a fix knowing every visitor sees it, with no support calls asking people to clear their cache.

Context

Steve's phone kept showing the dark sidebar after the light one had shipped. Measured on the live site before this change: the page itself, /, was served with an ETag and a Last-Modified date and no Cache-Control header at all. HTTP lets a browser reuse a response like that without asking, for a stretch it estimates from the file's age (the heuristic freshness rule in RFC 9111). A browser that held the old page could keep using it, and the old page names the old bundle files, which it held under the same rule. The API and the documents carried no cache headers either.

The old fix, from the days of hand-written script tags, was to append the file's date to its address, app.css?v=20260902, so a changed file had a changed address and no cache could confuse the two. A React app built with Vite does the same thing without anyone typing a date: every bundle file is named after a hash of its own contents, assets/index-a1b2c3d4.css, and index.html is rewritten at build time to point at the new names. A change in the CSS is a change in the file name, every build, with no hand on it. What the hash cannot fix is index.html itself, which keeps its address forever, and that is the file the phone was reusing.

Decision

Three cache rules, set in one place by one middleware, chosen from the shape of the address:

  • Hashed bundle files, /assets/*: public, max-age=31536000, immutable, a year. Their names change when their contents change, so a stale copy is impossible, and a browser may keep them as long as it likes, which is what makes the second visit fast. Only a 200 gets this header; a missing file is never remembered for a year.
  • Everything that can change under the same address: no-cache. The page, the API, the documents, the version. A browser may keep a copy but must ask before using it. The page answers those asks with 304 Not Modified from its ETag, which costs one round trip and no bytes.
  • The photo set, /api/images/*: unchanged, one day, as it already was. The photos do not change between builds.

The date in the address is not needed and not used. The hash in the file name is the same idea, done by the build, for every file, every time.

In the code

The rules, read from this build (api/TheYard.Api/Composition/RequestPipeline.cs):

// Cache rules (ADR-015), from the shape of the address. Registered before the
// files and the endpoints so its rule is in place on every response either writes. Vite names every
// bundle file by a hash of its contents, so /assets/* can be kept for a year
// and never goes stale: a new build has new names. Everything that can change
// under the same address (the page, the API, the documents) says no-cache, so
// a browser asks before reusing it. The photo set keeps its own one-day rule
// below, and a response that already chose its rule is left alone.
app.Use(async (context, next) =>
{
    context.Response.OnStarting(() =>
    {
        var response = context.Response;
        if (!response.Headers.ContainsKey("Cache-Control"))
        {
            bool hashedBundleFile = context.Request.Path.StartsWithSegments("/assets")
                && response.StatusCode == StatusCodes.Status200OK
                && !(response.ContentType ?? "").StartsWith("text/html", StringComparison.OrdinalIgnoreCase);
            response.Headers.CacheControl = hashedBundleFile
                ? "public, max-age=31536000, immutable"
                : "no-cache";
        }
        // The catalogue's two reads may be kept by the edge for a few seconds when the
        // answer is one every visitor would get; the browser's rule above is unchanged.
        CatalogueReads.LetTheEdgeKeep(context);
        return Task.CompletedTask;
    });
    await next();
});

Live from api/TheYard.Api/Composition/RequestPipeline.cs, region cache-headers, read from this build at 1d6b50b.

The proof is api/TheYard.Tests/CacheHeaderTests.cs: the API, the documents and the version say no-cache; a missing bundle file says no-cache rather than immutable; a photo keeps its day.

Consequences

  • A deploy is visible on the next load, on any device: the browser asks for the page, gets the new one, and the new one names new bundle files.
  • A device that loaded the site before this shipped still holds the old page under the old rule until it reloads once; after that the rules above apply.
  • The edge in front of the site was measured the same day as passing API responses through without caching them (Cache-Status fwd=miss), so the rules above reach the browser as written.
  • The Admin tab, the version endpoint and the documents are fetched fresh every time. They are small, and being current is their whole job.

Where it sits

The cache rules live in the host Api, in one inline middleware in Composition/RequestPipeline.cs (region cache-headers) that picks a rule from the shape of the address. The rule for every address is read in one place, with the photo set's one-day rule as the one exception. The cost is a round trip on every visit for the page and the API, answered with a 304 when nothing changed. A bundler that did not hash file names would force versioned addresses back into the page.

Files

More of the code

The static file middleware, the photo set's own rule, and the fallback that answers only app routes (api/TheYard.Api/Composition/RequestPipeline.cs):

// What: the page, the hashed bundle files and the photos, straight from disk.
// How: UseDefaultFiles turns "/" into "/index.html", and each UseStaticFiles
// answers a file it has and ends the request there; anything else carries on.
// Why here: ahead of routing, the user, the session and the store's warmth,
// none of which a file needs. Microsoft's order puts static files right after
// error handling for exactly this reason. Until the pipeline
// lane they ran last, so every file waited behind all of that, and the photos,
// which live under /api/images, waited for the catalogue to load after a roll.
// The cache rules above still wrap them, because those are registered first.
// The SPA fallback stays the last word (SpaRegistration.cs): it answers app
// routes with index.html, while an address that looks like a file stays a
// 404 rather than a page dressed as a script.
app.UseDefaultFiles();
app.UseStaticFiles();

app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(imagesRoot),
    RequestPath = "/api/images",
    // The photo set is content-stable; let the browser's HTTP cache keep it
    // for a day instead of re-fetching 50 JPEGs per session.
    OnPrepareResponse = ctx =>
        ctx.Context.Response.Headers.CacheControl = "public, max-age=86400",
});

Live from api/TheYard.Api/Composition/RequestPipeline.cs, region static-files, read from this build at 1d6b50b.

Addendum, 2026-09-28 (1.0.3.35): the edge is warmed after every deploy

The year-long rule above is why a hashed file costs nothing the second time. The first time is the edge's miss: the edge asks the origin, and until it has the file it has nothing to serve. Measured on the Cosmos DB site from Missouri at 10:30 CDT, before this change: the activity card's four chunks took about 700 ms on a miss (about 630 ms of it waiting on the origin) and 33 ms once the edge held them. Because every deploy renames every hashed file, the first visitor after every deploy paid that miss on every chunk they opened.

  • Both deploys warm the edge after the site answers (scripts/warm-edge.mjs, the step "Warm the edge" in deploy.yml and deploy-cosmos.yml). The script reads the build's file list off the site itself, through the public address: the page names its entry files and every script names the chunks it can load, so following those names reaches every file the build serves, with no manifest to publish. It asks for each file once, then for the Admin tab's public reads (/api/admin/activity for each window, /api/health, /api/admin/machines, /api/admin/pages), all with the deploy's own TheYard-SelfRead/1 (deploy) agent, so the Site activity card counts them as the site reading itself. A failure is a warning and never fails a deploy; DeployWorkflowTests holds the step to both workflows, after Verify.
  • What the warm-up can reach, and what it cannot. The edge caches a proxied response at the node that served it; each node keeps its own. A GitHub runner reaches the node nearest the runner, which warms the files for visitors near that node and not necessarily for one in Missouri. Netlify's shared layer that would carry one warm read to every node, the durable cache directive, applies to responses from Netlify Functions only, not to proxied responses (Netlify's caching overview, read 28 September), so it does not fit an edge that proxies to Azure. What does reach a given place is a read from that place, so the ship that pushes from Steve's machine in Missouri runs the same script against both sites once the roll answers.

Addendum, 2026-10-05 (1.0.3.76): measured again, and why the script still misses

Read from Missouri on 1.0.3.74, ten requests in a row for the hashed script on each site: six of twenty were hits and fourteen were misses, every miss marked stored. The headers this record sets are doing their job; the misses come from the edge keeping a separate cache per node, as the addendum of 28 September found, and from a site this quiet rarely landing on a warm one. Nothing in this repository can change that on the free plan. Netlify's shared durable layer still applies to its own Functions only. Serving the built files from the edge itself would end the misses, and it is priced in Edge deploy economics (ADR: Edge deploy economics, the addendum of 5 October). The numbers are on the Performance page.

Addendum, 2026-10-05 (1.0.3.77): the catalogue's reads leave compressed

The Performance page said on 17 September that server-side compression was not needed, because the edge serves everything text-shaped as Brotli. That holds for the browser's side of the edge. It does not hold for the hop behind it: the edge forwards every API read to Azure, and the listing came back to it as about 106 KB of plain JSON, which the edge then compressed to about 16 KB. Measured from Missouri on 1.0.3.74, the whole of that answer took 0.54 to 0.80 s straight from Azure, and the listing through the edge took 0.34 to 2.43 s to its first byte.

So the container now compresses the catalogue's two reads, the listing and the filter values (CatalogueReads), with Brotli or gzip, for any caller that asks. The level is the optimal one, which is Brotli's quality 4. The fastest level was tried first and sent 44 KB of the 106, because the serializer writes the answer in pieces and each piece is flushed through the compressor; at quality 4 the flushes cost little, and a hundred vehicles take about a millisecond. Nothing else is compressed. Those two bodies carry no secret, which is what keeps them safe from the BREACH attack: it needs a secret and text an attacker chooses in the same compressed body, and a session here travels in a cookie header, which response compression does not touch. Every other address leaves as it did. CompressionTests holds both halves, and the numbers after the roll are on the Performance page.

Addendum, 2026-10-05 (1.0.3.79): the edge's copy of the catalogue

Every API read said no-cache, so the edge forwarded each one to Azure and a visitor clicking through the listing waited on that hop every time. The listing and the filter values are now an exception for the edge alone. A 200 to either one carries, when the caller sent no cookie and no Authorization header and the answer sets no cookie, Netlify-CDN-Cache-Control: public, max-age=15, stale-while-revalidate=15: the edge may answer from its own copy for fifteen seconds and for fifteen more while it fetches a fresh one. The copy is kept per query string (Netlify-Vary: query) and per store (Vary: X-Yard-Store). The browser's rule does not change: Cache-Control still says no-cache, so a browser still asks every time.

What it costs, said plainly: a listing price or a sold flag can be up to thirty seconds behind on the listing grid. A vehicle's own page and its bids are not covered and are always read fresh, so the figure a buyer bids against is the live one. A signed-in request is answered fresh and never stored, so no shared copy holds anything a session produced. CacheHeaderTests holds the rule and the browser's unchanged no-cache; the numbers after the roll are on the Performance page.

Addendum, 2026-10-09 (1.0.3.110): the edge's copy of a page

The same rule, on the pages the rendering service draws. A page request that carries no cookie is answered with Netlify-CDN-Cache-Control: public, max-age=10, stale-while-revalidate=20 and Netlify-Vary: query,cookie=theyard_session, so the edge keeps a page drawn for nobody for ten seconds, per view, and a signed-in visitor is looked up under a key of their own. A request with any cookie is never kept. The browser still reads no-cache and asks every time. The decision and its cost are in ADR: A rendering service beside the API, the addendum on the edge's copy of a page; render/render.ts carries the rule and CacheHeaderTests holds the cookie's name to the API's.

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

  •    
  •    
  •    

How it is built

How it is run

Who and why