Cachen lyver oftere end din kode
Jeg har engang brugt en hel aften på at jagte en “mystisk bug” i noget JavaScript, for til sidst at opdage, at det eneste mystiske var, at min browser nægtede at hente den nye fil. Koden var fin. Min caching-strategi var det ikke.
Hvis du også har prøvet at deploye, trykke refresh og stadig se gammel opførsel, så er det ikke dig, der er skør. Det er HTTP caching, der gør præcis det, du har bedt den om. Bare lidt for godt.
1. Hvad caching egentlig gør i én sætning
Hvis jeg skal koge det helt ind: HTTP caching handler om, hvem der må genbruge et svar, hvor længe og på hvilke betingelser.
Der er tre “steder”, der typisk gemmer dine svar:
- Browseren – cache per bruger, per device.
- CDN / reverse proxy (Cloudflare, Vercel, Netlify, etc.) – cache tæt på brugeren, fælles for mange brugere.
- Din egen server – oftest ikke cache, men den sender reglerne i form af HTTP headers.
Alle tre kigger på de samme ting: især Cache-Control, ETag, Last-Modified og et par stykker mere.
Så i stedet for at huske “magiske” browser-genveje, er det langt rarere at vide, hvorfor din main.js ikke bliver opdateret, og hvad du kan gøre ved det.
2. De 6 headers du faktisk støder på
Cache-Control
Cache-Control er din hovedfjende og bedste ven i samme header. Den fortæller, hvor længe noget må ligge i cache, og hvem der må gemme det.
Cache-Control: public, max-age=31536000, immutable
Her siger du: alle caches (browser + CDN) må gemme svaret i op til 1 år, og filen ændrer sig ikke (immutable). Brug det til fingerprintede statiske filer, ikke til HTML.
Typiske direktiver:
- max-age=sekunder – hvor længe svaret er “friskt”.
- no-cache – må gerne gemmes, men skal revalidere hos serveren før brug.
- no-store – må slet ikke gemmes, hverken i browser eller CDN.
- private – kun browseren må gemme, ikke shared caches (CDN).
- public – må gemmes alle steder.
ETag
ETag er en slags fingeraftryk for indholdet. Hvis indholdet ændrer sig, ændrer ETag-værdien sig.
ETag: "abc123"
Når browseren har en cachet version, sender den den værdi tilbage næste gang:
If-None-Match: "abc123"
Hvis serveren stadig har samme version, svarer den med 304 Not Modified og ingen body. Browseren bruger så sin egen cachede body. Hurtigt, og du slipper for at sende det hele igen.
Last-Modified
Last-Modified er den lidt mere simple fætter til ETag. Den siger bare, hvornår ressourcen sidst blev ændret:
Last-Modified: Tue, 21 Jan 2025 10:15:00 GMT
Browseren kan så spørge:
If-Modified-Since: Tue, 21 Jan 2025 10:15:00 GMT
Og igen: hvis intet er ændret, svarer serveren med 304.
Expires
Expires er den gamle måde at sige “gyldig indtil”.
Expires: Wed, 22 Jan 2025 10:15:00 GMT
De fleste moderne setups bruger Cache-Control: max-age i stedet, men du ser tit begge to for bagudkompatibilitet.
Vary
Vary fortæller cache-laget, at svaret afhænger af en bestemt request-header.
Vary: Accept-Encoding
Det betyder: behold forskellige versioner afhængigt af fx om klienten understøtter gzip, br eller noget andet. På moderne sites ser du også:
Vary: Accept-Encoding, Accept-Language
Her kan samme URL have forskelligt indhold ud fra sprog.
Age
Age er en header, du typisk bare læser, når du debugger.
Age: 123
Det betyder, at svaret har ligget i en cache i 123 sekunder. Nyttigt, når du vil se, om du faktisk rammer CDN-cachen, eller hver request går direkte til backend.
3. no-cache, no-store og must-revalidate med rigtige eksempler
De tre ord lyder næsten ens, men de opfører sig meget forskelligt.
no-store
Cache-Control: no-store betyder: må aldrig gemmes nogen steder.
Brug det til ting som:
- Kontoudtog
- Personlige data
- Svar med følsomme oplysninger
Cache-Control: no-store
Her bliver hver request kørt igennem hele vejen til serveren, hver gang.
no-cache
Cache-Control: no-cache er misvisende navngivet. Det betyder ikke “ingen cache”. Det betyder:
Må gerne gemmes, men skal altid tjekkes hos serveren, før det bruges.
Cache-Control: no-cache
ETag: "v1"
Browseren kan godt gemme svaret, men når brugeren beder om URL’en igen, sender browseren If-None-Match. Hvis server svarer 304, bruger browseren sin lokale cache. Du får både kontrol og hastighed.
must-revalidate
must-revalidate er en ekstra sikkerhedsline. Den siger: når max-age er udløbet, må cachen ikke bruge et gammelt svar uden at spørge serveren først.
Det er især interessant ved ustabile netværk, hvor en proxy ellers kunne finde på at servere “stale” (for gammel) cache.
Et mønster, jeg ofte bruger til HTML-sider:
Cache-Control: no-cache, must-revalidate
Så kan browsers og CDNs gemme, men der revalideres altid, når brugeren genindlæser.
4. 304 Not Modified: godt tegn, indtil det ikke er det
304 Not Modified betyder: “Du har allerede den nyeste version, brug bare den”.
Flowet ser typisk sådan ud:
- Første gang: browseren får
200 OKmed body +ETag. - Næste gang: browseren sender
If-None-Match. - Serveren tjekker: er ETag uændret?
- Hvis ja: svar med 304 og næsten ingen data.
Det er godt, fordi:
- Du sparer båndbredde.
- Du bevarer kontrol over, hvornår noget er forældet.
Det bliver et problem, når:
- Du deployer ny kode, men din server af en eller anden grund genbruger samme ETag.
- Du tror, at noget ikke er cachet, men du reelt bare får 304 hver gang.
Når du får en 304, er det ikke serverens skyld, at du ser gammel adfærd. Den siger jo bare “ingenting er ændret”. Så hvis du lige har deployet, og du stadig ser 304 på din main.js, er problemet typisk:
- Manglende eller forkert cache busting (ingen fingerprint i filnavn).
- Build-systemet genbruger gamle filnavne.
- CDN, der ikke er blevet invalideret korrekt.
5. Static assets: fingerprint og lang cache
For statiske filer som JS, CSS og billeder findes der et mønster, der næsten altid er det rigtige: fingerprint + lang cache.
Idéen
I stedet for at hedde main.js hver gang, hedder din fil noget ala:
main.4f7a1c3.js
Det her hash (fingerprintet) ændrer sig, når indholdet ændrer sig. Så du kan gøre to ting:
- Sætte lang cache på filen (f.eks. 1 år).
- Sørge for, at din HTML peger på det nye filnavn efter hvert build.
Eksempel på headers til sådan en fil:
Cache-Control: public, max-age=31536000, immutable
immutable siger til browseren: “den her fil ændrer sig aldrig, så længe den har det her navn”. Og det passer, fordi navnet kun ændrer sig, når du bygger nyt.
Hvorfor dine ændringer ikke slår igennem uden fingerprint
Hvis du bliver ved med at bruge main.js uden hash, sker der typisk det her:
- Du sætter fx
Cache-Control: max-age=3600(1 time). - Du deployer ny kode efter 5 minutter.
- Brugere, der har hentet
main.jslige efter deploy, sidder nu med en cachet version, som browseren er helt tilfreds med at bruge i op til en time.
Resultat: du ser nogle brugere på gammel adfærd, andre på ny. Det er her, mange får lyst til at spamme F12 og hard refresh, men det løser kun problemet for dig, ikke for alle andre.
De fleste moderne bundlere (Vite, Webpack, Parcel osv.) understøtter fingerprint out-of-the-box. Så tjek, at din production build faktisk laver filenames med hash, og at din HTML-peger på dem.
Hvis du vil læse mere om hvordan det spiller sammen med performance, har jeg også skrevet om simple performance-gevinster i Core Web Vitals uden panik.
6. API-responses: cache nogle ting, men ikke alt
For API-kald er caching lidt mere følsomt. Du vil typisk have tre slags data:
- Næsten statiske (f.eks. en liste over lande, konstanter).
- Hyppigt læste, sjældent opdaterede (f.eks. produktdata).
- Personlige eller meget dynamiske (f.eks. brugerens nuværende kurv, profil, notifikationer).
Sådan kunne du tænke det
Eksempler:
Statiske opslagsdata:
Cache-Control: public, max-age=86400
ETag: "countries-v3"
Her kan både CDN og browser gemme svaret i 1 dag. Når du opdaterer listen, ændrer du ETag-versionen.
Produktdata til et site med ikke alt for store trafiktal:
Cache-Control: public, max-age=60, stale-while-revalidate=300
Her siger du: svaret er friskt i 60 sekunder. I op til 5 minutter efter kan cachen godt servere en lidt gammel version, mens den i baggrunden henter en frisk. Det hjælper især med performance, men du skal være ok med, at data kan være et par minutter bagud.
Bruger-specifikke data (fx kurv):
Cache-Control: private, no-cache
ETag: "cart-123"
Kun browseren må cache, og der skal revalideres ved refresh. Ingen CDN må gemme dine brugeres kurvdata.
Hvis du bygger API’er, der skal være til at leve med på længere sigt, giver det også mening at tænke cache ind sammen med de andre designvalg. Der er en artikel om hvordan du undgår det kaos-API, jeg selv startede med, som også er værd at have i baghovedet.
7. Debugging i Chrome DevTools: sådan ser du, om du rammer cache
Hvis du kun skal tage én vane med herfra, så er det den her: åbn Network-tabben og kig på, hvad der faktisk sker.
Trin-for-trin i Chrome
- Åbn siden i Chrome.
- Højreklik, vælg “Inspicer” (eller Cmd+Opt+I / Ctrl+Shift+I).
- Gå til fanen Network.
- Sæt evt. flueben i “Disable cache”, hvis du vil se rene requests (kun gældende mens DevTools er åbent).
Nu kan du se alle requests. Klik på f.eks. din main.js. Kig på:
- Status: 200, 304, (from disk cache), (from memory cache).
- Headers: hvad svarer serveren med af Cache-Control, ETag, etc.
Hvis du ser “from disk cache” eller “from memory cache”
Så betyder det, at browseren slet ikke har sendt noget til serveren. Den har bare sagt “jeg har det her liggende, det er stadig friskt, jeg bruger det bare”. Hvis du lige har deployet, og du stadig får det, er problemet typisk, at din max-age er for høj, og at du ikke bruger fingerprinted filnavne.
Hvis du ser 304 Not Modified
Så bliver serveren faktisk spurgt. Problemet, hvis der stadig er gammel kode, kan være:
- Serveren genbruger samme ETag, selvom koden er ændret.
- Et CDN har cachet et gammelt svar, og backend ser aldrig revalidations-forespørgslen.
En god lille øvelse er at prøve at skifte mellem “Disable cache” til og fra og se, hvad der ændrer sig. Så får du et mental billede af, hvor hårdt cachen slår til.
Hvis du ikke er vant til Network-tabben, kan du også læse min tidligere artikel om hvordan Chrome DevTools Network pludselig gav mening.
8. En simpel caching-opskrift til små webapps
Hvis du bare vil have noget, der virker for en typisk SPA eller lille webapp på Vercel/Netlify/Render, så er her et setup, jeg ofte ender med.
HTML (entry point, f.eks. index.html)
- Status: må gerne gemmes kortvarigt, men skal være nem at opdatere.
Cache-Control: no-cache, must-revalidate
Så kan browseren godt gemme, men hver gang brugeren loader siden, bliver der tjekket, om der er en nyere version.
Fingerprintede JS/CSS-filer
- Status: må gerne caches hårdt.
Cache-Control: public, max-age=31536000, immutable
Husk: det kræver, at filnavnene ændrer sig ved nyt build. Det klarer de fleste bundlere, hvis du bygger i “production mode”.
Billeder og fonte
Hvis de også er fingerprintede (f.eks. logo.abc123.png):
Cache-Control: public, max-age=31536000, immutable
Hvis du har “manuelle” billeder, du skifter ind imellem (f.eks. hero.jpg, uden hash):
Cache-Control: public, max-age=3600
Eller lav selv et simpelt cache-bust i URL’en, f.eks. hero.jpg?v=2, når du opdaterer billedet.
API-responses
Forslag til startsetup:
- GET /api/public-data (ret statisk):
Cache-Control: public, max-age=300 - GET /api/user (privat):
Cache-Control: private, no-cache - POST/PUT/DELETE:
Cache-Control: no-store
Det er ikke perfekt til alle systemer, men det er et fornuftigt udgangspunkt, du kan justere fra.
Mini-øvelse
Hvis du vil teste din forståelse, så prøv:
- Åbn et eksisterende projekt, du har liggende online.
- Gå i Network, filtrer på “JS”.
- Noter for hver fil: har den hash i navnet? Hvad siger Cache-Control?
- Vurdér: svarer det nogenlunde til mønstret ovenfor, eller er alt bare “standard” uden omtanke?
Det giver et ret ærligt billede af, hvordan din nuværende deploy-strategi egentlig opfører sig.
9. Hvor går du videre herfra?
Det næste, jeg selv ville gøre, er at koble caching sammen med resten af performance-billedet. F.eks. hvordan lange caches på statiske filer spiller sammen med bundle-størrelse, lazy loading og den slags. Der er allerede lidt i artiklen om Core Web Vitals, men jeg har en mistanke om, at caching får sin helt egen lille nørdede serie på et tidspunkt.
Spørgsmålet er egentlig bare: hvor meget vil du kunne stole på, at dit næste deploy rent faktisk rammer brugerne, uden at du først skal forklare dem, hvordan de laver en hard refresh?







Send kommentar
Du skal være logget ind for at skrive en kommentar.