Cache-Control headers i 2026: Komplet guide til HTTP-caching, stale-while-revalidate og cache-invalidering

Lær hvordan Cache-Control-headere styrer browser- og CDN-caching i 2026. Guide til max-age, s-maxage, stale-while-revalidate, ETag og korrekt invalidering.

Cache-Control Headers Guide 2026

Opdateret: 6. september 2026

Cache-Control er den HTTP-header, der styrer hvor længe browsere og CDN'er må genbruge et svar uden at spørge dit origin igen. I 2026 er det stadig det billigste enkeltstående performance-trick, der findes. En korrekt konfigureret Cache-Control: public, max-age=31536000, immutable på fingeraftrykte assets flytter LCP fra sekunder til millisekunder for gengangende brugere, mens stale-while-revalidate og edge-invalidering med surrogate keys holder dynamisk HTML både frisk og lynhurtig. Ærligt talt: jeg har brugt fem år på at rydde op i cache-konfigurationer, og næsten hver eneste incident stammer fra tre steder: forkert s-maxage, manglende Vary, eller et invalideringssystem, der ikke er koblet til deploy-pipelinen.

  • Cache-Control er den kanoniske caching-header i HTTP/1.1, HTTP/2 og HTTP/3. Expires og Pragma er legacy og bør ignoreres.
  • max-age gælder for browser og alle mellemliggende caches, mens s-maxage overskriver max-age for shared caches som CDN'er.
  • stale-while-revalidate serverer et forældet svar øjeblikkeligt og fornyer det i baggrunden. Perfekt til produktlister, feeds og ISR-lignende workloads.
  • Fingeraftrykte assets (/app.[hash].js) bør have max-age=31536000, immutable. HTML bør have kort TTL plus stale-while-revalidate.
  • ETag med conditional GET sparer båndbredde, men koster stadig en round-trip. immutable undgår selv 304-tjekket.
  • Cache-invalidering på CDN skal ske via surrogate keys / cache tags fra deploy-pipelinen, ikke via manuel URL-purge.

Hvad er Cache-Control-headeren?

Cache-Control er den HTTP-response-header, der fortæller alle mellemliggende parter (browser, service worker, corporate proxy, CDN, reverse proxy) hvordan et svar må gemmes og genbruges. Den blev defineret i RFC 7234 og senere præciseret i RFC 9111 (juni 2022), som stadig er den gældende specifikation i 2026. Du sætter den på responses fra dit origin, og den skal indeholde en eller flere kommaseparerede direktiver: public, private, max-age=N, s-maxage=N, no-cache, no-store, must-revalidate, proxy-revalidate, immutable, stale-while-revalidate=N, stale-if-error=N.

Den vigtigste sondring er mellem freshness og validation. Freshness betyder "må jeg genbruge det uden at spørge origin?", og styres af max-age / s-maxage. Validation betyder "må jeg spørge origin billigt med en conditional GET?", og styres af ETag og Last-Modified. En moden cache-strategi bruger begge lag: freshness giver dig nul round-trips, mens validation giver dig 304-svar på 100 bytes i stedet for at genhente 500 KB.

I mit team behandler vi Cache-Control som en del af API-kontrakten på lige fod med statuskoder. Vi review'er den i pull requests, tester den i CI med curl -I-snapshots, og alarmerer i Grafana hvis en endpoint pludselig begynder at sende no-store. Det lyder overdrevet, indtil første gang en junior har committet Cache-Control: no-cache på jeres logo. Så forstår alle hvorfor.

Alle Cache-Control-direktiver forklaret

Direktiverne opdeles i to grupper: dem der styrer freshness, og dem der styrer hvem der må cache. Her er den komplette liste med den betydning RFC 9111 giver dem i 2026:

DirektivHvem gælder det?Effekt
max-age=NBrowser + sharedSvaret er frisk i N sekunder
s-maxage=NKun shared (CDN, proxy)Overskriver max-age for CDN
publicAlleMå caches selv med Authorization-header
privateKun browserCDN/proxy må ikke gemme svaret
no-cacheAlleMå gemmes, men skal revalideres før genbrug
no-storeAlleMå ikke gemmes overhovedet
immutableBrowserSend aldrig conditional GET, indholdet ændrer sig aldrig
must-revalidateAlleEfter TTL: revalider eller returnér 504
stale-while-revalidate=NAlleServer stale i N sek. efter TTL mens der revalideres i baggrunden
stale-if-error=NAlleServer stale i N sek. hvis origin fejler

Det mest misforståede direktiv er no-cache. Det betyder ikke "cache ikke svaret". Det betyder "cache det, men spørg origin før hver genbrug". Hvis du vil forhindre lagring, skal du bruge no-store. Det næstmest misforståede er public: du behøver det kun, hvis dit svar har en Authorization-header, men alligevel må caches (fx offentlige profil-endpoints).

Forskellen på max-age og s-maxage

max-age=N gælder for enhver cache i kæden: browseren, en corporate proxy, dit CDN, alt. s-maxage=N gælder kun for "shared" caches (CDN, reverse proxy) og overskriver max-age for dem. Det er det vigtigste værktøj til at have to forskellige TTL'er i samme header: én for browseren og én for edge.

Et konkret eksempel fra en produkt-side jeg optimerede sidste kvartal:

Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400

Denne header betyder: browseren beholder svaret i 60 sekunder (så tilbage-navigation føles øjeblikkelig), Cloudflare beholder det i 1 time (så vi kun rammer origin ~24 gange i døgnet per URL), og hvis Cloudflare får en request efter timen er gået, serverer den stale-svaret straks mens den henter et nyt i baggrunden. Resultatet? 96% cache hit-ratio på edge og en p95 TTFB på 34 ms globalt, uden at HTML nogensinde blev mere end en time gammel for edge-brugere.

Hvis du kun sætter max-age=3600, arver browseren også den time. Det betyder, at brugere ser samme priser i op til en time, selv efter du har deploy'et en prisændring, med mindre du kombinerer det med invalidering. Og hvis du vender det på hovedet og sætter max-age=60, s-maxage=60, får du 60 gange flere origin-requests end nødvendigt. Læs mere om origin-latens i vores TTFB-optimeringsguide, som forklarer hvorfor edge-caching er det største enkeltstående TTFB-værktøj.

Sådan virker stale-while-revalidate i praksis

stale-while-revalidate=N (SWR) blev standardiseret i RFC 5861 og understøttes af alle større CDN'er, Chromium, Firefox og Safari i 2026. Mekanikken er enkel: efter max-age udløber, må cachen fortsat servere det gamle svar i op til N ekstra sekunder, mens den samtidigt sender en asynkron revalideringsrequest til origin. Næste bruger får det friske svar; nuværende bruger ventede ikke.

Det ændrer fundamentalt hvordan du designer caching. Uden SWR sætter du enten kort TTL (mange origin-hits, lav frihed for stale data) eller lang TTL (få origin-hits, gamle data). Med SWR kan du sætte max-age=0, s-maxage=10, stale-while-revalidate=59 og få en effektiv TTL på 10 sekunder for friskhed og 60 sekunder for hastighed. Ingen bruger venter nogensinde på origin. Første bruger efter udløb får stale, alle efterfølgende får frisk. Dette mønster ligner det Next.js kalder Incremental Static Regeneration, men det er ren HTTP og virker på ethvert framework.

En vigtig detalje: SWR skal understøttes af det cache-lag, du forlader dig på. Cloudflare aktiverede SWR globalt i 2024, Fastly har haft det siden 2014 (deres stale-while-revalidate VCL-syntaks), og Vercel Edge Network respekterer det på alle planer. Browseren respekterer det også, men hvis dit CDN allerede har SWR aktiveret, ser browseren kun det friske svar, og SWR-direktivet får ingen effekt på klienten. Jeg foretrækker at sætte det på edge, hvor det tæller mest.

ETag vs Last-Modified: hvilken skal du bruge?

Både ETag og Last-Modified er validators. De lader browseren spørge origin "har det ændret sig?" og få et 304 Not Modified retur uden body. Forskellen ligger i granularitet og pålidelighed.

Last-Modified er en dato med 1 sekunds præcision. Det er hurtigere at generere (bare en fil-timestamp), men det håndterer ikke sub-sekund-ændringer, og bit-identiske svar med forskellige timestamps bliver ineffektive. ETag er en opaque streng, du selv definerer, typisk en hash af svaret eller en versionsnøgle. Det er dyrere at generere, men langt mere præcist, og det kan detektere semantisk identiske svar (samme hash = samme svar).

Min anbefaling: brug ETag for alt dynamisk indhold, og skip begge dele for fingeraftrykte assets. Grunden er, at fingeraftrykte assets skal have immutable, hvilket får browseren til at springe conditional GET helt over. Så en ETag ville aldrig blive tjekket. Se den officielle MDN-dokumentation for Cache-Control for de præcise regler.

// Express-eksempel: svag ETag baseret på indholdshash
import { createHash } from 'node:crypto';

app.get('/api/products', async (req, res) => {
  const body = JSON.stringify(await db.products.list());
  const etag = 'W/"' + createHash('sha1').update(body).digest('base64') + '"';

  res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=300');
  res.setHeader('ETag', etag);

  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end();  // 100 bytes i stedet for hele payloaden
  }
  res.type('application/json').send(body);
});

Vary-headeren og cache-fragmentering

Vary fortæller cachen, at responset afhænger af bestemte request-headers. Samme URL kan altså give forskellige svar, og hver variant skal caches separat. Uden korrekt Vary vil du servere gzip-komprimeret indhold til klienter, der ikke forstår gzip, eller engelsk HTML til danske brugere. Med for aggressiv Vary fragmenterer du cachen så meget, at hit-ratioen kollapser.

De tre Vary-værdier, du næsten altid bør sætte, er:

  • Vary: Accept-Encoding: når du serverer forskellige komprimeringer (gzip, br, zstd). Næsten alle servere håndterer denne automatisk i 2026.
  • Vary: Accept: når du serverer forskellige formater (JSON vs HTML, WebP vs JPEG).
  • Vary: Accept-Language: hvis du serverer sprogvarianter på samme URL. Bedre alternativ: brug særskilte URL'er per sprog.

Undgå Vary: User-Agent og Vary: Cookie. Begge fragmenterer cachen så voldsomt, at edge-hit-ratio typisk falder under 5%. Hvis du er nødt til at variere på cookies, brug en normaliseret cookie-værdi (kun de relevante nøgler), og læg logikken i en Cloudflare Worker eller Vercel middleware.

Sådan invaliderer du CDN-cache korrekt

Cache-invalidering er, som Phil Karlton berømt sagde, et af datalogiens to svære problemer. For mit vedkommende er det også det, jeg bruger mest tid på i praksis. Der er tre invalideringsstrategier i 2026, og du skal vælge én bevidst:

  1. Time-based (TTL). Sæt en kort s-maxage og lad tiden gøre arbejdet. Enklest, mest pålidelig, men du accepterer op til TTL sekunders forsinkelse.
  2. URL purge. Kald CDN'ets API med en specifik URL og bed den droppe cachen. Fungerer for enkeltsager, men bryder sammen, når 100 sider deler samme datakilde.
  3. Surrogate keys (cache tags). Tag hvert svar med logiske nøgler (product-42, category-shoes), og purge alt der matcher en tag. Dette er det eneste skalerbare mønster.

Fastly opfandt surrogate keys, og både Cloudflare (Cache Tags på Enterprise), Vercel (revalidateTag) og Akamai understøtter varianter i 2026. Mønstret er: dit CMS eller din database emitterer et event, når en entitet ændres, en worker læser eventet, og CDN-API'et purger alle svar tagget med den entitet. Latens fra publish til globalt friskt indhold: 200–800 ms.

// Cloudflare Worker der tagger svar med surrogate keys
export default {
  async fetch(request, env) {
    const response = await fetch(request);
    const url = new URL(request.url);
    const tags = deriveTagsFromPath(url.pathname);  // fx ["product-42", "category-shoes"]

    const modified = new Response(response.body, response);
    modified.headers.set('Cache-Tag', tags.join(','));
    modified.headers.set('Cache-Control', 'public, s-maxage=86400, stale-while-revalidate=3600');
    return modified;
  }
};

// Deploy-hook der purger tags når et produkt opdateres
async function purgeProduct(productId, env) {
  await fetch(`https://api.cloudflare.com/client/v4/zones/${env.ZONE}/purge_cache`, {
    method: 'POST',
    headers: { 'Authorization': `Bearer ${env.API_TOKEN}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ tags: [`product-${productId}`] })
  });
}

Nøglen er at koble purge til dit datalag, ikke til dine URL'er. Når et produkt opdateres, ved jeg måske ikke, hvilke 40 sider der viste det, men jeg ved, hvilke tags de blev serveret med. Så jeg purger tag'et og lader CDN'et regne resten ud. Dette mønster hedder også "cache dependency tracking" og er kernen i frameworks som Next.js App Router og Remix. Der er en beslægtet idé i vores gennemgang af Speculation Rules API, hvor prerender også lever af friske edge-svar.

Anbefalede opskrifter for assets, HTML og API

Her er de fire opskrifter, jeg starter enhver ny frontend med. De dækker 95% af tilfældene:

Fingeraftrykte statiske assets (JS, CSS, fonts, billeder)

Cache-Control: public, max-age=31536000, immutable

1 år, immutable, ingen conditional GET. Filnavnet indeholder en indholdshash (app.a1b2c3.js), så en ny version = ny URL. Kombinér med resource hints som preload og fetchpriority for at hente kritiske assets tidligere.

HTML til statisk / semi-statisk indhold

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=86400

Browseren revaliderer hver navigation (billigt via ETag), edge cacher i 60 sekunder, SWR giver stale svar i et døgn, hvis origin fejler eller ved trafik-spikes.

HTML til fuldt personlige sider (dashboard, konto)

Cache-Control: private, no-store

Ingen caching, ingen deling. Sørg for at wrappe dette i en middleware, der aldrig tillader andet.

API-svar (GET, offentlige data)

Cache-Control: public, max-age=30, s-maxage=300, stale-while-revalidate=3600
Vary: Accept-Encoding, Accept
ETag: W/"..."

Kort browser-TTL (så åbne faner ikke bliver stale), længere edge-TTL, SWR-buffer, og en ETag så conditional GET stadig virker efter TTL. Sammen med back/forward cache giver dette næsten øjeblikkelig tilbage-navigation.

Implementering i Node, Nginx og Cloudflare Workers

Her er de tre platforme, jeg oftest møder. Bemærk at koden i alle tre eksempler er idémæssigt identisk. Cache-Control er trods alt bare en header.

Node/Express middleware

function cacheControl(directives) {
  return (req, res, next) => {
    res.setHeader('Cache-Control', directives);
    next();
  };
}

app.use('/static', cacheControl('public, max-age=31536000, immutable'), express.static('public'));
app.use('/api/products', cacheControl('public, max-age=30, s-maxage=300, stale-while-revalidate=3600'));
app.use('/dashboard', cacheControl('private, no-store'));

Nginx

location ~* \.(js|css|woff2|avif|webp|jpg|png)$ {
    add_header Cache-Control "public, max-age=31536000, immutable" always;
    expires 1y;
}

location /api/ {
    add_header Cache-Control "public, max-age=30, s-maxage=300, stale-while-revalidate=3600" always;
    add_header Vary "Accept-Encoding, Accept" always;
    proxy_pass http://origin;
}

location /dashboard {
    add_header Cache-Control "private, no-store" always;
    proxy_pass http://origin;
}

Cloudflare Worker

const RULES = [
  { pattern: /\.(js|css|woff2|avif|webp)$/, header: 'public, max-age=31536000, immutable' },
  { pattern: /^\/api\//, header: 'public, max-age=30, s-maxage=300, stale-while-revalidate=3600' },
  { pattern: /^\/dashboard/, header: 'private, no-store' },
];

export default {
  async fetch(request) {
    const response = await fetch(request);
    const url = new URL(request.url);
    const rule = RULES.find(r => r.pattern.test(url.pathname));
    if (!rule) return response;

    const modified = new Response(response.body, response);
    modified.headers.set('Cache-Control', rule.header);
    return modified;
  }
};

Uanset platform: skriv en integrationstest, der bruger curl -I mod en staging-URL og asserterer på headere. En cache-header-regression bør fange sig selv i CI, ikke i en incident. Læs den fulde specifikation i RFC 9111: HTTP Caching, hvis du vil se den præcise ordlyd bag hvert direktiv.

De fem cache-fejl jeg oftest ser i produktion

Efter at have audit'et cache-strategier i cirka 30 forskellige codebases, er der fem fejl, der dukker op igen og igen:

  1. Ingen Cache-Control overhovedet. Browsere har heuristics (typisk 10% af Last-Modified-alder), der cacher svar, de teknisk set ikke må. Du vil ikke overraske dig selv: sæt altid en eksplicit header.
  2. no-cache hvor no-store var meningen. Klassisk XSS-workaround, der ender med at fylde disk-cachen med hemmelige data. Følsomme svar skal have Cache-Control: private, no-store.
  3. Manglende Vary: Accept-Encoding. CDN'et cacher det brotli-komprimerede svar og serverer det til en klient, der ikke understøtter brotli. Sjældent i 2026, men jeg så det stadig i to audits i år.
  4. Ens TTL for browser og CDN. Så snart du sætter max-age=3600, arver browseren timen, så invalidering på edge hjælper ingen bruger, der allerede har HTML'en i sin browser. Split med s-maxage.
  5. Manuel URL-purge i stedet for tags. Fungerer i 3 måneder, bryder sammen første gang I omstrukturerer URL-rewrites eller tilføjer et nyt filter. Invester i surrogate keys tidligt.

Ofte stillede spørgsmål

Hvad er forskellen på no-cache og no-store?

no-cache betyder, at svaret må gemmes, men skal revalideres mod origin før hver genbrug (typisk via ETag). no-store betyder, at svaret ikke må gemmes overhovedet, hverken i browser, disk-cache eller CDN. Brug no-store for følsomme personlige data, og brug no-cache når du vil have hurtige 304-svar men altid frisk indhold.

Hvor lang bør max-age være for statiske assets?

Et år (max-age=31536000) er standard for fingeraftrykte assets. Det er også den maksimale værdi, CDN'er som Cloudflare respekterer. Kombinér altid med immutable, så browseren springer conditional GET over. Uden filnavn-fingeraftryk må du bruge kortere TTL (typisk 60–300 sekunder) og forlade dig på ETag.

Hvorfor cacher mit CDN ikke min HTML?

De tre almindeligste årsager er: du sender en Set-Cookie-header (mange CDN'er springer caching over automatisk), du bruger private eller no-store i Cache-Control, eller du sender en Vary-header med User-Agent eller Cookie, som fragmenterer cachen så meget, at hits er usandsynlige. Tjek response-headere med curl -I og se efter CDN-specifikke debug-headere som cf-cache-status.

Understøtter alle browsere stale-while-revalidate?

Ja. Chrome/Edge siden version 75, Firefox siden version 68, Safari siden version 14 (2020). Alle større CDN'er (Cloudflare, Fastly, Vercel, Akamai, CloudFront) understøtter direktivet i 2026. Klienter, der ikke forstår det, ignorerer det simpelthen og opfører sig, som om kun max-age var sat.

Bør jeg bruge Expires-headeren i 2026?

Nej. Expires er en HTTP/1.0-header, som er blevet erstattet af Cache-Control: max-age. Alle moderne caches ignorerer Expires når Cache-Control er sat, og de fleste servere fjerner den automatisk. Send kun Cache-Control, og drop Expires og Pragma. De tilføjer kun forvirring og ekstra bytes.

Mateo Silva
Om Forfatteren Mateo Silva

Full-stack performance lead bridging frontend perf with backend latency. Cache invalidation is his love language.