Har din cache også løjet for dig?

Har din cache også løjet for dig?

Forestil dig at du deployer en vigtig bugfix, trykker refresh, alt ser fint ud… og så skriver brugeren fem minutter efter: “Jeg har stadig fejlen”. Du kigger på koden, den er jo opdateret. Men deres browser viser stadig den gamle verden.

De fleste gange hvor jeg har haft den slags “spøgelsesbugs”, har det været cache-control der drillede. Ikke fordi HTTP caching er ondt, men fordi jeg ikke helt forstod hvem der gemte hvad, og hvor længe.

Hvem gemmer egentlig hvad, og hvornår?

Jeg starter lige med den mentale model, jeg selv ville ønske nogen havde tegnet for mig tidligere.

Tre lag der kan cache dine svar

Typisk har du mindst tre lag i spil:

  • Browseren – gemmer svar lokalt hos brugeren
  • CDN eller reverse proxy (Cloudflare, Vercel, Netlify, nginx osv.) – gemmer svar tæt på brugeren
  • Din app/server – selve kilden der genererer svaret

Alle tre kan være inde i billedet på samme request. Og alle tre kigger på de samme HTTP headers: især Cache-Control, ETag, Last-Modified og nogle gange Vary.

To typer caching: gem eller spørg først

Der er groft set to strategier:

  • Freshness: “Det her svar er gyldigt i X sekunder” (max-age)
  • Revalidation: “Spørg mig lige, før du bruger det igen” (ETag / If-None-Match)

Freshness er hurtigst. Revalidation er sikrest. Det interessante er kombinationen af dem.

Cache-Control i praksis: de direktiver du faktisk bruger

Her er et eksempel på en Cache-Control header, jeg selv har brugt forkert mange gange:

Cache-Control: no-cache

Da jeg først så no-cache, troede jeg det betød “ingen caching”. Det gør det ikke. Det betyder “du må gerne gemme det, men du skal spørge mig, før du bruger det igen”.

max-age: hvor længe er svaret frisk?

max-age siger hvor mange sekunder svaret må bruges uden at spørge serveren igen.

// 1 time i browseren
Cache-Control: public, max-age=3600

public betyder at både browser og mellemled (CDN) må cache det. Uden public kan nogen proxies være mere forsigtige.

s-maxage: specielt til CDN og shared caches

s-maxage er som max-age, men kun til shared caches (typisk CDN).

// CDN må gemme i 1 dag, browser kun i 5 minutter
Cache-Control: public, max-age=300, s-maxage=86400

Det mønster er ret nyttigt til HTML-sider: brugeren får hurtige svar fra CDN, men browseren tør du ikke lade cache for længe, fordi du gerne vil kunne “tvinge” opdatering.

no-store vs no-cache: den klassiske misforståelse

no-store: intet må gemmes nogen steder. Hver request skal hele vejen til serveren. Bruges til ting som login-sider, personlige dashboards med følsomme data osv.

no-cache: må gerne gemmes, men skal revalidere før brug. Så svaret kan ligge på disk, men browseren sender en hurtig “må jeg bruge det her?” forespørgsel.

// Ingen caching overhovedet
Cache-Control: no-store

// Gem det, men spørg før du bruger det igen
Cache-Control: no-cache

Jeg har selv brugt no-cache når jeg mente “ingen cache”. Resultat: ekstra requests, forvirring og stadig mærkelig adfærd.

ETag og If-None-Match: sådan foregår revalidation

ETag er i praksis bare et stykke tekst, serveren sætter på svaret, så den kan genkende indholdet senere.

Hvordan ETag-flowet ser ud i virkeligheden

  1. Bruger henter /style.css
  2. Server svarer med:
HTTP/1.1 200 OK
ETag: "v1-abc123"
Cache-Control: max-age=0, must-revalidate

/* CSS-indhold */
  1. Næste gang browseren skal bruge /style.css, sender den:
GET /style.css
If-None-Match: "v1-abc123"
  1. Hvis indholdet ikke har ændret sig, svarer serveren:
HTTP/1.1 304 Not Modified

// Ingen body, browser bruger sin cachede version

Fordelen: du sparer båndbredde, og browseren får sikkerhed for, at indholdet er det samme.

Svag vs stærk ETag (den korte version)

Du kan støde på noget som:

ETag: W/"123456"

W/ betyder “weak”. Browseren må stadig bruge svaret, men svage ETags kan betyde “næsten det samme”. Personligt bruger jeg kun ETag hvis mit framework sætter det for mig. Ellers versionerer jeg filer via filnavne (for CSS/JS) og bruger Last-Modified eller slet ingenting til HTML.

stale-while-revalidate: hurtig oplevelse uden at opgive friskhed

stale-while-revalidate er et relativt nyt direktiv, som mange CDNs og moderne browsere forstår. Det lyder kompliceret, men ideen er simpel: “Brug det gamle svar med det samme, men begynd i baggrunden at hente et nyt”.

Et eksempel på en header med stale-while-revalidate

Cache-Control: public, max-age=60, stale-while-revalidate=300

Det betyder:

  • I de første 60 sekunder er svaret friskt (ingen revalidation)
  • Efter 60 sekunder må cache stadig bruge det gamle svar i op til 300 sekunder, men samtidig trigge en baggrundsrevalidation

Det føles for brugeren som om siden altid er hurtig, og din origin-server får færre “kolde” hits.

Hvornår er stale-while-revalidate en god ide?

Jeg bruger det gerne til:

  • Statiske sider der opdateres sjældent (blogindlæg, dokumentation)
  • API-svar med data der ikke skal være sekund-præcist (statistikker, “seneste artikler” osv.)

Jeg undgår det til:

  • Data hvor en forsinkelse giver rod (ordrestatus, saldo osv.)
  • Personligt indhold hvor hver bruger skal se sit helt eget billede af verden

Tre opskrifter der dækker 90% af dine use cases

Nu til den del jeg selv manglede: hvad sætter man rent faktisk på hvilke svar? Det her er ikke den eneste sandhed, men det er et fornuftigt udgangspunkt.

1) Statiske assets (CSS, JS, billeder, fonde)

Her vil du gerne cache så hårdt som muligt, når først filen har et unikt navn. Det klassiske mønster:

/assets/app.abc123.css
/assets/app.abc123.js
/images/logo.7f9e2.png

Når du deployer, ændrer du hash-delen i filnavnet. Browser og CDN kan derfor cache for evigt, fordi en ny version altid har et nyt navn.

Headers kan se sådan her ud:

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

immutable fortæller browseren at filen aldrig ændrer sig, så den behøver ikke engang at revalidere.

Typisk fejl jeg selv har lavet: bruge lang cache uden filnavn-versionering. Resultat: brugere ser gamle styles i dagevis, indtil deres cache naturligt udløber.

2) HTML-sider

HTML er tit der hvor “cachen lyver” føles værst. Du vil gerne have hastighed, men du har også brug for at kunne ændre indholdet uden at vente på 24 timers cache-expiry.

Et ofte fornuftigt kompromis hvis du har CDN foran:

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

Oversat:

  • Browser: må bruge svaret i 60 sekunder
  • CDN: må bruge svaret i 600 sekunder (10 minutter)
  • CDN: må bruge det lidt for gamle svar i yderligere 600 sekunder, mens det henter friskt i baggrunden

Hvis du vil være endnu mere forsigtig (f.eks. for admin-sider), kan du sige:

Cache-Control: no-store

Og så lade serveren selv stå for eventuel intern caching.

3) API responses

Her afhænger det meget af data-type og klienter, men der er nogle mønstre.

API med sjældent ændrede data

Eksempel: liste over lande, valutaer, produktkategorier.

Cache-Control: public, max-age=3600, stale-while-revalidate=86400
ETag: "countries-v1"

Det gør svarene hurtige og billige, og du kan roligt leve med at nogen ser en lidt gammel liste i få sekunder.

API med ofte ændrede eller bruger-specifikke data

Eksempel: “mine ordrer”, “min profil” eller realtids-dashboard.

Cache-Control: private, no-store

private betyder at svaret kun må caches i brugerens egen browser, ikke i shared caches. Kombineret med no-store betyder det i praksis: ingen caching.

Hvis du vil have ETag-revalidation men ingen browser-cache, kan du gøre noget i stil med:

Cache-Control: private, max-age=0, must-revalidate
ETag: "orders-xyz"

Browseren må gerne gemme en kopi, men skal altid spørge før den bruger den.

Sådan ser du hvad der faktisk sker i Chrome DevTools

Det tog mig pinligt lang tid at opdage, at DevTools i bund og grund fortæller dig alt om caching, hvis du kigger det rigtige sted.

Trin 1: Tænd for de rigtige kolonner

Åbn DevTools, gå til fanen Network, højreklik på kolonne-headeren og slå f.eks. disse kolonner til:

  • Status
  • Type
  • Size
  • Time
  • Waterfall

Tryk F5 og kig på “Size”-kolonnen. Hvis der står (from disk cache) eller (from memory cache), ved du at browseren ikke snakkede med netværket.

Trin 2: Læs Response Headers

Klik på en række, gå til fanen Headers, og kig efter:

  • Response Headers: Cache-Control, ETag, Last-Modified
  • Request Headers: If-None-Match, If-Modified-Since

Hvis du ser If-None-Match, er browseren i gang med revalidation. Kommer svaret tilbage som 304 Not Modified, har revalidation virket.

Trin 3: Brug “Disable cache” når du fejlsøger

I Network-fanen er der en lille checkbox: Disable cache. Den gælder kun mens DevTools er åben, men den er guld værd når du vil være sikker på, at et problem ikke “bare” er cache.

Mit typiske flow når jeg er i tvivl:

  1. Åbn DevTools
  2. Sæt flueben i Disable cache
  3. Hard reload (hold Shift nede og tryk reload-knappen)
  4. Tjek at responsen nu er frisk (ingen (from disk cache))

Hvis fejlen forsvinder der, er caching stærkt mistænkt.

Tre cache-fejl jeg selv har lavet mere end én gang

Jeg kunne sikkert lave en længere liste, men de her tre går igen.

Fejl 1: Lang cache på HTML uden nogen form for versionering

Setup: statisk site på et CDN, jeg var glad for performance og satte:

Cache-Control: public, max-age=86400

Det virkede fint… indtil jeg skulle rette en skrivefejl på forsiden, og nogen stadig så den gamle side dagen efter. Jeg havde ingen måde at “invaliderer” deres cache på.

Bedre løsning: kortere max-age på HTML, længere på assets. Og så bruge “Purge cache” i CDN-konsolem når jeg absolut skal presse en opdatering igennem.

Fejl 2: API blev cachet bag CDN uden jeg lagde mærke til det

Jeg havde bygget et lille JSON-API til et sideprojekt og deployet det på en platform med indbygget CDN. Jeg satte aldrig nogen specifikke cache-headers. CDNen valgte alligevel at gemme svar for mig.

Resultat: en bruger opdaterede data, men deres GET-kald til APIet viste stadig gamle værdier i op til et minut. På min maskine virkede det, fordi jeg ofte bypassede CDN i udviklingsmiljøet.

Løsning: være eksplicit:

Cache-Control: private, no-store

på alle API-endpoints hvor hver enkelt request skal være frisk.

Fejl 3: Manglende Vary-header gjorde alt for aggressiv caching

Vary fortæller caches, at et svar afhænger af en given header. Et klassisk eksempel er sprog:

Vary: Accept-Language

Jeg havde en endpoint der returnerede forskelligt indhold afhængigt af en Authorization-header, men ingen Vary. CDN gemte bare første svar og brugte det til senere brugere.

Hvis du bygger noget lignende, så overvej:

Vary: Authorization

eller endnu bedre: cache ikke det svar i shared caches. Typisk med Cache-Control: private, no-store.

Tjekliste før du skruer caching op i produktion

Inden du går all-in på cache-control og aggressive værdier, kan du stille dig selv et par konkrete spørgsmål.

1. Hvad er det værste der sker, hvis nogen ser data der er X minutter gamle?

Hvis svaret er “de ser en gammel artikeloverskrift”, så er det nok fint med 5-10 minutters cache. Hvis svaret er “de får vist forkert saldo på deres konto”, så ved du godt hvad du skal svare.

2. Ved jeg hvordan jeg vil opdatere ting hurtigere end max-age?

Til HTML-sider på et CDN: har du en purge-knap du stoler på?

Til assets: bruger du hashes i filnavnene, så du kan lade gamle filer leve i cache i fred?

Hvis ikke, så skru lidt ned indtil du har en strategi.

3. Har jeg tjekket headers i DevTools, ikke bare gættet?

Tag et par kernesider og API-kald på dit site, åbn Network og kig helt konkret på:

  • Cache-Control
  • ETag / If-None-Match
  • Status-koder: 200 vs 304

Gør det både med og uden “Disable cache”. Så kan du se forskellen, i stedet for at gætte på, hvad der sker.

4. Har jeg skelnet mellem HTML, assets og API i min konfiguration?

Det er fristende at sætte én global regel i fx nginx eller et CDN og så være færdig. Men HTML, statiske filer og API har ofte brug for helt forskellige regler.

Hvis du bruger et statisk site, der bygger på noget som Next.js eller Astro, så kig i deres dokumentation om anbefalede cache-headers. Der er ofte fornuftige defaults. Du kan f.eks. starte med at læse om caching på MDN’s side om HTTP caching og så mappe det til dit framework.

Hvor kan du gå videre herfra?

Hvis du har læst hertil, er du allerede foran de fleste, der bare skruer på cache-knappen indtil “det føles hurtigt”. Et naturligt næste skridt er at lege med et lille testprojekt, hvor du bevidst sætter forskellige Cache-Control-kombinationer og ser effekten i DevTools.

Du kan også kombinere det her med andre performance-greb du måske allerede kender, som at optimere assets, bruge moderne bundlere eller sætte et simpelt build-setup op, som jeg var inde på i en anden artikel om workflow. Og hvis du arbejder full stack og gerne vil forstå hele kæden fra browser til server, hænger caching godt sammen med forståelsen af API-design, som i artiklen om pagination og API-responser på codingclass.dk.

Jeg synes caching er et af de områder, hvor en lille investering i forståelse giver uforholdsmæssigt meget ro i maven senere. Spørgsmålet er mest: hvor aggressivt tør du cache, nu hvor du ved hvordan du slår det fra igen?

Brug browserens Network-fane: se statuskoder (304 betyder revalidation), og tjek headers som Age, CF-Cache-Status eller X-Cache. Sammenlign med curl -I til samme URL for at se serverens headers direkte, og prøv en forespørgsel med Cache-Control: no-cache for at tvinge revalidation. De to kilder viser ofte om det er browseren, CDN'en eller origin som holder fast i en gammel kopi.
Indfør fingerprinting: inkluder en content-hash i filnavnet (f.eks. main.abc123.js) så URL ændres ved hver build. Det fjerner behovet for at purge caches, og kombineret med korte browser-cache-tider for HTML sikrer det hurtig fremvisning af opdateret markup.
Serveren sender en ETag som unik identifikation af ressourcen; klienten sender ETag i If-None-Match ved næste forespørgsel, og serveren returnerer 304 Not Modified hvis indholdet er uændret. Det er nyttigt for dynamisk genererede sider hvor du vil spare båndbredde uden at underminere caching; brug stærke ETags hvis byte-for-byte lighed er vigtigt.
Brug CDN-udbyderens purge API eller dashboard (Cloudflare, Vercel, Netlify har alle værktøjer til dette), men vær opmærksom på at fulde purges kan være langsomme og globale. Foretræk cache-busting via nye filnavne ved deploy eller indstil kort s-maxage under rollout for at undgå store, risikable purge-operationer.

Mikkel Schrøder er den dér stille type, der i årevis har siddet om aftenen med en kop kaffe og et åbent kodeprojekt, mens resten af huset er ved at falde til ro. Hans interesse for kodning startede, da han som teenager forsøgte at lave en simpel hjemmeside til sit favorit-fodboldhold og opdagede, at man kunne ændre alt ved at rode med HTML og CSS. Siden har han lært tingene ved at prøve sig frem, læse forumtråde og pille ved små projekter, indtil de gjorde det, han ville.

På Coding Class deler han ikke perfekte løsninger fra et glansbillede-univers, men de ting han faktisk selv har bokset med: mærkelige JavaScript-fejl, CSS der ikke opfører sig som forventet, og små Python-scripts, der starter i kaos og ender med at spare tid i hverdagen. Han kan godt lide at vise både den første, halvdårlige løsning og den forbedrede udgave, så du kan se forskellen og forstå tankegangen bag.

Mikkel brænder for at gøre programmering mindre skræmmende for dem, der ikke ser sig selv som "tech-typer". Derfor skriver han på helt almindeligt dansk, med små, konkrete kodeeksempler og fokus på, hvordan du selv kan komme fra teori til noget, der faktisk virker. På Coding Class forsøger han at bygge bro mellem manual-sproget og virkeligheden ved at vise, hvordan det føles at sidde med fejlen klokken 22.30 – og hvad der skulle til, før den forsvandt.

Send kommentar

You May Have Missed