Hoe HTTP-caching beslist wat opnieuw wordt opgehaald
Open je voor de tweede keer een website, dan gaat dat merkbaar sneller. Een deel van de bestanden komt dan helemaal niet over de lijn, maar uit een cache op je eigen schijf. Een ander deel wordt wel opnieuw opgevraagd, alleen komt er dan geen bestand terug maar een kort antwoord: niks veranderd, gebruik wat je hebt. Welke van de twee het wordt, staat in een paar regels in de HTTP-headers.
In dit artikel lees ik die headers bij wallieweb.nl zelf en bij een groot CDN. Je ziet hoe een browser uitrekent of een bestand nog vers is, wat een 304 Not Modified bespaart en wat niet, en waarom de HTML van deze site nooit wordt bewaard. De regels komen uit RFC 9111 (caching) en RFC 9110 (HTTP-semantiek), allebei van juni 2022.
Twee soorten caches
RFC 9111 onderscheidt een private cache en een shared cache. De private cache hoort bij één gebruiker; dat is de cache in je browser. Een shared cache bewaart antwoorden voor meerdere gebruikers. Denk aan een CDN dat dichtbij staat, zoals in het artikel over anycast, of een proxy in een bedrijfsnetwerk.
Een opgevraagd bestand kan dus drie lagen passeren: browser, CDN en de oorspronkelijke server, de origin. Elke laag stelt dezelfde vraag. Heb ik dit al, en mag ik het gebruiken zonder verder te vragen?
Om iets terug te vinden gebruikt een cache een cache key. Die bestaat minimaal uit de methode (meestal GET) en de volledige URL. Met de header Vary kan de server er request-headers aan toevoegen. De stylesheet van wallieweb komt met Vary: Accept-Encoding, zodat een gecomprimeerde en een ongecomprimeerde versie niet door elkaar raken.
Vers of verlopen
De kernregel uit RFC 9111 is één vergelijking:
response_is_fresh = (freshness_lifetime > current_age)
Een antwoord is vers zolang het jonger is dan zijn houdbaarheid. Zo'n vers antwoord mag de cache gebruiken zonder de server iets te vragen. Voor de houdbaarheid kijkt een cache naar de eerste regel die van toepassing is. Voor een shared cache telt eerst s-maxage, daarna voor iedereen max-age uit de Cache-Control-header. Ontbreken die, dan geldt Expires min Date.
Staat er helemaal niets, dan mag een cache zelf een houdbaarheid schatten. De RFC noemt als gebruikelijke waarde 10 procent van de tijd sinds Last-Modified. Een bestand dat tien dagen niet is veranderd, kan zo tot een dag uit de cache komen zonder dat iemand dat heeft afgesproken.
De leeftijd zie je soms terug in de header Age: de schatting van een cache van het aantal seconden sinds de origin het antwoord maakte of voor het laatst bevestigde.
Wat wallieweb.nl meestuurt
Vanaf een Nederlandse verbinding vroeg ik de headers op van een artikelpagina en de bestanden die erbij horen:
| Bestand | Cache-Control |
|---|---|
| HTML-pagina | private, no-cache, no-store, max-age=0, must-revalidate |
logo.svg | public, max-age=0 |
stylesheet, scripts, lettertype in /_next/static/ | public, max-age=31536000, immutable |
De HTML is het strengst. no-store betekent volgens de RFC dat geen enkele cache, private of shared, iets van het antwoord mag bewaren. Wat er verder in de header staat, maakt dan niets meer uit. Wordt een artikel opnieuw geladen, dan komt de HTML dus opnieuw van de server.
Het logo mag wel bewaard worden, maar met max-age=0 is het meteen verlopen. Een verlopen antwoord mag een cache alleen zonder navragen gebruiken als hij geen verbinding heeft of als dat uitdrukkelijk is toegestaan. In de praktijk vraagt de browser dus bij elk gebruik na of het logo nog klopt.
De statische bestanden mogen 31.536.000 seconden mee, precies 365 dagen. Dat kan omdat er volgens de documentatie van Next.js een hash in de bestandsnaam zit. 3s1ya1mjfzqpw.css blijft altijd dezelfde stylesheet; een nieuwe versie krijgt een andere naam en daarmee een andere cache key.
Navragen met een ETag
Een verlopen antwoord hoeft niet weg. De cache kan het revalideren: de server vragen of zijn kopie nog klopt. Daarvoor stuurt de server een validator mee. Het logo kreeg er twee:
Last-Modified: Thu, 08 Oct 2026 17:16:54 GMT
ETag: W/"5f9-1a11c849a95"
Bij de volgende keer zet de cache die waarden in de vraag: If-None-Match met de ETag, of If-Modified-Since met de datum. Is er niets veranderd, dan antwoordt de server met 304 Not Modified. Dat antwoord heeft alleen headers; inhoud mag er volgens RFC 9110 niet in zitten. Krijgt een server beide headers, dan moet hij If-Modified-Since negeren. De ETag geldt als de nauwkeurigere vergelijking.
Een ETag is voor de client een betekenisloze tekst. Bij wallieweb is wel te zien hoe hij ontstaat. 5f9 is hexadecimaal voor 1529, precies de grootte van het logo in bytes. 1a11c849a95 is de wijzigingstijd in milliseconden: 8 oktober 2026, 17:16:54,805 UTC.
De W/ ervoor maakt hem zwak. Een sterke validator moet volgens RFC 9110 veranderen bij elke wijziging van de inhoud. Grootte plus tijd garandeert dat niet helemaal. Ook een voorbeeld uit de RFC klopt hier: de gzip-versie van de stylesheet (12.359 bytes) krijgt dezelfde ETag als de ongecomprimeerde (73.719 bytes). Een validator die bij twee verschillende reeksen bytes hoort, is per definitie zwak. Voor If-None-Match is dat geen probleem, want daar schrijft de RFC de zwakke vergelijking voor.
Wat een 304 bespaart
Ik vroeg de stylesheet van 73.719 bytes drie keer gewoon op en drie keer met de ETag erbij, telkens over een nieuwe verbinding:
| Vraag | Antwoord | Bytes | Totale tijd |
|---|---|---|---|
| gewoon | 200 | 73.719 | 171 tot 188 ms |
met If-None-Match | 304 | 0 | 145 tot 146 ms |
Het scheelt hier 25 tot 43 milliseconden en alle bytes. Een nieuwe verbinding kost eerst de handshakes uit Wat er gebeurt vóór de eerste byte, en ook over een bestaande verbinding gaat er een vraag heen en een antwoord terug. Revalideren is goedkoper dan opnieuw downloaden, maar alleen een vers antwoord uit de cache kost niets.
Daar is immutable voor (RFC 8246, september 2017). Volgens die RFC stuurt een browser bij herladen voorwaardelijke vragen, ook voor bestanden die nog vers zijn. Bij een pagina met veel onderdelen kunnen dat er honderden zijn. Met immutable belooft de server dat het bestand tijdens zijn houdbaarheid niet verandert. Clients horen dan niet na te vragen, behalve als de gebruiker een geforceerde herlaadactie doet.
Het CDN ertussen
Bij een CDN ziet het er anders uit. Ik vroeg jQuery op bij code.jquery.com, dat volgens RIPEstat op adressen van Fastly draait:
cache-control: public, max-age=31536000, stale-while-revalidate=604800
age: 1682667
via: 1.1 varnish, 1.1 varnish
x-served-by: cache-lga21978-LGA, cache-rtm-ehrd2290040-RTM
x-cache: HIT, HIT
Dit bestand kwam uit een shared cache. Age is 1.682.667 seconden: de origin heeft deze kopie ruim 19 dagen geleden gemaakt of voor het laatst bevestigd. Volgens de documentatie van Fastly noemt x-served-by de cacheserver die het antwoord leverde, en met shielding kunnen er meer servers in staan. Hier waren het er twee. Bij een vraag anderhalve minuut eerder stond op de tweede plek nog een cache-ams-....
stale-while-revalidate=604800 komt uit RFC 5861. Een cache mag het bestand dan na het verlopen nog maximaal 604.800 seconden (zeven dagen) blijven geven, en hoort intussen op de achtergrond na te vragen of het nog klopt. De bezoeker wacht dus niet op die controle.
Zelf proberen
Headers bekijken:
curl -4 -sI https://wallieweb.nl/logo.svg | grep -iE 'cache-control|etag|last-modified'
Cache-Control: public, max-age=0
Last-Modified: Thu, 08 Oct 2026 17:16:54 GMT
ETag: W/"5f9-1a11c849a95"
Een voorwaardelijke vraag sturen met die ETag:
curl -4 -s -o /dev/null -w '%{http_code} %{size_download}\n' -H 'If-None-Match: W/"5f9-1a11c849a95"' https://wallieweb.nl/logo.svg
304 0
Een CDN-cache aan het werk zien:
curl -4 -sI https://code.jquery.com/jquery-3.7.1.min.js | grep -iE 'age|x-cache|x-served-by'
age: 1682667
x-served-by: cache-lga21978-LGA, cache-rtm-ehrd2290040-RTM
x-cache: HIT, HIT
Tot slot
Ik had verwacht dat een 304 veel meer tijd zou schelen. De bytes vallen weg, maar de verbinding en de round trip blijven, en die waren in mijn meting het grootste deel van de wachttijd. Echte winst zit in antwoorden die vers genoeg zijn om niet te hoeven vragen. Daarom krijgen bestanden met een hash in hun naam een jaar, en de HTML die naar die namen verwijst helemaal niets. De andere verrassing was de ETag van mijn eigen site: geen hash van de inhoud, maar gewoon de bestandsgrootte en de wijzigingstijd van het bestand.