Skip to content

    Frontend System Design

    HTTP Caching for the Front End

    Learn how freshness, validation and Cache-Control work in the browser cache, and choose header policies for hashed assets, HTML pages and APIs.

    By Published 17 min read

    • frontend-system-design
    • http-caching
    • cache-control
    • etag
    Share

    This lesson is part of Frontend System Design Foundations: How the Web Runs. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to explain how the browser decides to reuse, revalidate or refetch a response, read and write the main Cache-Control directives, design the "hashed filenames plus revalidated HTML" strategy, tell a browser cache from a shared cache such as a CDN, and recognise the caching bugs that break deployments.

    A milk carton has a use-by date. Before it, you drink without a second thought. After it, you might phone the shop and ask whether this batch is still good. The date is set by the dairy, not by you, and a wrong date hurts either way: you bin good milk, or you drink bad milk.

    HTTP caching works the same way. The server stamps each response with how long it may be reused without asking, and with a cheap way to ask afterwards. Stamp too short and users re-download files that never changed; stamp too long and they keep seeing things that did.

    The running example is an online store with one JavaScript bundle (app.3f9c2e1a.js), product photos, a product API (/api/products/1234) and HTML pages (/products/1234). The file names and all lifetimes below are illustrative. For the back-end view of caches, see caching basics, advanced caching and CDN and object storage; Sibling lessons cover how a browser works, rendering strategies and loading performance; this one stays at the HTTP layer.

    The core idea

    Every stored response is in one of two states, defined by RFC 9111 (HTTP Caching).

    • Fresh: younger than its freshness lifetime. The cache may reuse it with no network request at all.
    • Stale: the lifetime has passed. The cache may not reuse it as is, but it can revalidate: ask the server "is my copy still current?" and, if so, get a tiny answer instead of the whole file.

    From cheapest to dearest, a request is served fresh from the cache (no network), revalidated with a 304 answer (a round trip, no body), or fully downloaded. A good policy pushes requests toward the cheap end without ever showing wrong content.

    The browser's HTTP cache is a private cache: it belongs to one user on one device. Between browser and server there may also be shared caches (a CDN, a reverse proxy) that serve stored responses to many users. Both obey the same Cache-Control rules, with a few directives aimed at one kind.

    Figure 1. Where a response can be cached on its way to the shopper

    Caches between the shopper's browser and the store's serversBrowser HTTP cache to CDN edge cache (miss or stale); CDN edge cache to Store server (miss or stale); CDN edge cache to Image storage (image misses)SHOPPER'S DEVICESHARED CACHEBrowser HTTP cache: private, one userBrowser HTTPcacheprivate, one userCDN edge cache: many usersCDN edge cachemany usersStore server: HTML and APIStore serverHTML and APIImage storage: product photosImage storageproduct photosmiss or stalemiss or staleimage misses

    How to read this: a request travels right only when the cache on its left cannot answer. The browser cache is the only layer that can answer with no network traffic at all.

    Browsers also partition the HTTP cache so a copy cached on one site is not reused by another; Chrome began in version 86, and other browsers partition differently. A third layer, Service Workers and the Cache API, is covered near the end.

    What the browser does with a request

    Figure 2. The browser's decision for a cacheable request

    flowchart TD accTitle: How a browser cache handles a request accDescr: A request first looks for a stored response. A fresh one is used with no network request. A stale one inside a stale-while-revalidate window is used while a background revalidation runs. Other stale responses with a validator are revalidated with a conditional request, and a 304 reuses the stored body while a 200 replaces it. Everything else goes to the network. A["Request for a URL"] --> B{"Stored response?"} B -->|no| N["Fetch from network, store if allowed"] B -->|yes| C{"Fresh?"} C -->|yes| U["Use stored copy, no network"] C -->|no| S{"Inside stale-while-revalidate window?"} S -->|yes| W["Use stale copy, revalidate in background"] S -->|no| D{"Has ETag or Last-Modified?"} D -->|no| N D -->|yes| V["Send conditional request"] V --> E{"Server answer"} E -->|"304"| R["Update headers, use stored body"] E -->|"200"| P["Replace stored copy, use new body"]

    How to read this: the upper exits are the cheap ones. A response marked no-cache skips the "Fresh?" question and goes straight to the conditional request; one marked no-store never reaches this diagram because nothing was stored.

    Freshness: how long a copy may be reused

    RFC 9111 section 4.2.1 says a cache takes the first lifetime that applies: s-maxage (shared caches only), then max-age, then the older Expires date minus the response's Date.

    Cache-Control: max-age=600 means fresh for 600 seconds after the origin generated the response; the clock starts at generation, not receipt. A cache that holds a copy can report its age in the Age header (RFC 9111 section 5.1). If a CDN hands a browser a response with max-age=60 and Age: 50, the browser has only about ten seconds of freshness left, so stacked caches can disagree about staleness for the same URL.

    Heuristic freshness: when you give no lifetime

    With no explicit lifetime, a cache may guess. RFC 9111 section 4.2.2 allows a heuristic lifetime for heuristically cacheable status codes (RFC 9110 lists 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414 and 501) and suggests a fraction of the time since Last-Modified, which "might be 10%". It gives no algorithm, so browsers choose their own. Illustratively, a file last modified 10 days ago could be treated as fresh for about one day. You do not control that guess, so send an explicit Cache-Control, as MDN advises.

    Validation: asking cheaply whether a copy is still good

    A stale copy can be checked with a conditional request that replays a validator the server sent earlier:

    • ETag: an opaque identifier of one version, such as "v7", replayed in If-None-Match. It may be weak (W/"v7": semantically equivalent rather than identical bytes; RFC 9110 section 8.8.1).
    • Last-Modified: a date, replayed in If-Modified-Since.

    When both are sent, the server must ignore If-Modified-Since (RFC 9110 section 13.1.3). If the validator matches, the server answers 304 Not Modified with no body; the cache refreshes the stored headers and serves its stored body (RFC 9111 section 4.3.4). Otherwise the server sends a normal 200 that replaces the copy.

    Figure 3. Revalidating the product API response

    sequenceDiagram accTitle: Conditional request for a stale product response accDescr: The page asks for a product. The browser cache finds a stale copy with an ETag, sends a conditional request, and the server answers 304 with no body, so the stored body is served. If the product changed, the server answers 200 with a new body instead. participant P as Page participant C as Browser cache participant S as Store server P->>C: GET /api/products/1234 Note over C: stored copy is stale, ETag "v7" C->>S: GET with If-None-Match "v7" alt unchanged S-->>C: 304 Not Modified, no body C-->>P: stored body, headers refreshed else changed S-->>C: 200, new body, ETag "v8" C-->>P: new body, copy replaced end

    How to read this: the page never sees the 304. The cache turns it into an ordinary response, so your fetch code looks the same whatever the source.

    A 304 still costs a round trip, so revalidation saves bandwidth, not latency. A stale copy with no validator gives the cache nothing to ask with, so it must download again. For files that never change, the best answer is no request at all, which drives the strategy below.

    The Cache-Control directives you will actually use

    These are response directives. The "Defined in" column matters, because they do not all come from one document.

    Directive Defined in What it means
    max-age=N RFC 9111 Fresh for N seconds after the origin generated the response.
    s-maxage=N RFC 9111 Like max-age but for shared caches only; overrides max-age and Expires there; private caches ignore it.
    no-cache RFC 9111 May be stored, but must be revalidated before every reuse. It does not mean "do not cache".
    no-store RFC 9111 Do not store the response or request in any cache.
    public RFC 9111 Any cache may store it, even when the request had an Authorization header.
    private RFC 9111 Only a private cache (the browser) may store it.
    must-revalidate RFC 9111 Once stale, never reuse without successful validation.
    immutable RFC 8246 Will not change while fresh, so clients should not send conditional requests in that time, such as on a normal reload.
    stale-while-revalidate=N RFC 5861 A cache may serve a stale response for N more seconds while revalidating in the background.
    stale-if-error=N RFC 5861 A cache may serve a stale response up to N seconds past freshness if the origin errors (500, 502, 503 or 504).

    no-cache versus no-store is the classic trap. Use no-cache for content that must always be checked but is cheap to serve (HTML); use no-store when nothing may be kept. RFC 9111 warns that no-store is not a reliable privacy mechanism, since a misbehaving cache might ignore it. Protect secrets with access control, not a header.

    The extensions come from different places. RFC 5861 is an Informational document, which RFC 9111 section 4.2.4 names as an example of extensions permitting stale responses; immutable is in RFC 8246, a Standards Track document. Support varies: web.dev reported stale-while-revalidate in Chrome 75 and Firefox 68 as of July 2019 and said a browser without support ignores it and falls back to max-age; support in other browsers today is not confirmed here. Check the MDN compatibility tables and your CDN's documentation. MDN also says stale-if-error as a request directive is not supported by any browser.

    An illustrative header set for the product API:

    Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=60
    ETag: "v7"

    Browsers and shared caches may store it; a browser treats it as fresh for 60 seconds, a CDN for 300; after that either may serve the old copy for up to 60 more seconds while revalidating.

    Browser cache versus shared cache

    Question Browser (private cache) CDN or proxy (shared cache)
    Whose copy? One user's Served to many users
    private May store Must not store
    s-maxage Ignored Used instead of max-age
    Request had Authorization Normal rules Not reused unless the response has must-revalidate, public or s-maxage (RFC 9111 section 3.5)
    Remove a bad copy Not possible; you cannot reach into a user's browser Usually through the provider's purge feature

    The last row is the design fact. You can fix a bad CDN entry in minutes; a bad browser entry stays until it expires or the user clears it. Long browser lifetimes are safe only when the URL changes whenever the content does. A cookie alone does not make a response private (MDN), so personalised responses need an explicit private or no-store.

    Vary: one URL, several responses

    When one URL returns different bodies depending on a request header (language, compression), the Vary response header lists those headers. A cache must not reuse a stored response unless they match the original request's (RFC 9111 section 4.1); Vary: * never matches. MDN warns that varying on User-Agent drastically cuts reuse, and recommends Cache-Control: private rather than a cookie in Vary for personalised content.

    The standard strategy: fingerprinted assets, revalidated HTML

    You want the bundle cached as long as possible, but you also want a fix to reach every shopper at once. One URL cannot be both "cache forever" and "changes today".

    The answer is to stop reusing URLs. Put a content hash (a short digest of the file's bytes) in the file name, called fingerprinting or cache busting. When the file changes its name changes, so to every cache it is a new resource.

    // webpack.config.js (excerpt): the file name includes a hash of the content
    module.exports = {
      output: { filename: "[name].[contenthash].js" },
    };

    Other bundlers can do the same; check your tool's documentation. The HTML then references the hashed names:

    <link rel="stylesheet" href="/assets/app.9d41b7c0.css">
    <script type="module" src="/assets/app.3f9c2e1a.js"></script>
    <img src="/img/p1234.7a8b21.webp" alt="Blue kettle" width="400" height="400">

    Hashed files get a long lifetime and immutable; the HTML that names them is revalidated every time. MDN shows this pairing:

    # /assets/* and /img/* (names contain a content hash)
    Cache-Control: max-age=31536000, immutable
    
    # /products/1234 (the HTML)
    Cache-Control: no-cache
    ETag: "page-v91"

    31536000 is one year in seconds, a conventional "as long as possible" and a choice, not a requirement.

    Figure 4. A deployment under this strategy

    sequenceDiagram accTitle: Returning shopper after a deployment with fingerprinted assets accDescr: Before the deploy the browser stored the HTML and a bundle named with hash 3f9c. After the deploy the browser revalidates the HTML, receives new HTML naming a bundle with hash 8b2d, downloads only that bundle, and reuses the stored product image with no request. participant B as Browser participant S as Store server Note over B: stored: HTML, app.3f9c.js, p1234.webp Note over S: deploy ships app.8b2d.js B->>S: GET /products/1234 with If-None-Match S-->>B: 200, new HTML names app.8b2d.js B->>S: GET /assets/app.8b2d.js (not stored) S-->>B: 200, max-age one year, immutable Note over B: p1234.webp is fresh, no request sent

    How to read this: the HTML is the only mutable pointer. Revalidating it costs one small round trip and reveals the new bundle name; everything unchanged costs nothing.

    Why it works:

    1. The URL is the cache key. A long lifetime is dangerous only if a URL's content can change. Here a published URL always means the same bytes.
    2. Only the entry point needs freshness. The small HTML is revalidated; the heavy files it points to are not.
    3. A user gets old HTML with the old bundle or new HTML with the new one, never a mix, because the HTML decides which names load.

    It makes one demand: old files must outlive the HTML that names them. A shopper's tab open since yesterday still holds yesterday's HTML. Delete yesterday's bundle on deploy and that tab breaks the next time it loads a lazy chunk.

    TIP

    Hash the content, not the build. A timestamp or build number in the name changes even when the file did not, and makes shoppers re-download unchanged code.

    What to cache for each kind of response

    Ask two questions: does the URL change when the content does, and is the response the same for every user?

    Response Example policy (illustrative) Reasoning
    Hashed JS, CSS and product images max-age=31536000, immutable The URL changes with the content, so a year is safe.
    Image at a stable URL (a logo) max-age=86400 plus an ETag The name never changes, so accept up to a day of staleness (your tolerance may differ).
    Shared HTML (same for everyone) no-cache plus an ETag Always revalidate; an unchanged page costs a 304.
    Personalised HTML no-cache, private (MDN's pairing) Revalidated and kept out of shared caches.
    Public API (a product) public, max-age=60, s-maxage=300, stale-while-revalidate=60 Short in the browser, longer in the CDN, refreshed in the background.
    Fast-changing data (price, stock) no-cache plus an ETag, or a very short max-age Staleness is costly; the 304 keeps it cheap.
    Per-user API (cart, account) private, no-cache; no-store if sensitive Never in a shared cache; sensitive data not stored.
    Error responses no-store or a few seconds Never inherit the long lifetime of success.

    These are example policies, not rules; the numbers are placeholders. Choose lifetimes by asking how stale each response may be before someone is harmed, as in caching basics. A CDN may also apply its own rules on top of your headers, and defaults differ by provider.

    Check what is really sent:

    # Headers of the hashed bundle: look for Cache-Control and Age
    curl -sI https://store.example/assets/app.3f9c2e1a.js
    
    # Replay a validator; a 304 means the server honours conditional requests
    curl -sI -H 'If-None-Match: "page-v91"' https://store.example/products/1234

    The second command prints a 304 Not Modified status line only if the server still holds that exact ETag.

    Reload, hard reload and "Disable cache"

    These are browser behaviours, not part of the HTTP specifications, and they can differ between browsers and versions. The table follows MDN and Chrome's documentation at the time of writing.

    Action What the browser typically does
    Normal navigation Uses the cache as in Figure 2: fresh copies need no request.
    Reload Sends max-age=0, so stored copies are revalidated with If-None-Match and If-Modified-Since. MDN notes Chrome no longer revalidates subresources on reload.
    Hard reload Sends Cache-Control: no-cache and Pragma: no-cache, unconditionally, so you get a 200. MDN notes Safari's requests differ.
    Back and forward Not guaranteed to revalidate; the page may be restored from the back/forward cache (bfcache).
    DevTools "Disable cache" Chrome's docs say it disables the browser cache, emulating a first-time visitor.

    The same modes exist in code as the cache option of fetch (MDN, Request.cache): { cache: "no-cache" } revalidates before using a stored copy, and { cache: "reload" } skips the cache for the request but stores the response. A hard reload is what developers press when something looks wrong; shoppers never do. Test a fix with a normal navigation in a profile that already holds the old version.

    Service Workers and the Cache API: a different layer

    A Service Worker is a background script that can intercept your page's requests; the Cache API is a store your JavaScript fills and empties. It is not the HTTP cache: it has no freshness rules, so whatever you put there stays until your code removes it. A service worker answers first; if it calls fetch(), the HTTP cache rules above still apply beneath it (web.dev). Offline support and update strategies are a separate topic.

    Common caching bugs

    Stale HTML pointing at deleted bundles. HTML is cached for long somewhere (a CDN rule, a heuristic) and a deploy removes the bundle names it mentions, leaving a blank page. Fix: no-cache HTML everywhere and keep old assets for a while.

    Caching an error. A response is storable if its status is final and it carries an explicit lifetime such as max-age (RFC 9111 section 3), so a blanket one-year rule under /assets/ also covers a 404 or 503 from a bad deploy. A 404 is even heuristically cacheable with no header. Fix: long lifetimes only on success responses.

    Caching a personalised response in a shared cache. The CDN stores shopper A's logged-in page and serves it to shopper B, usually because of public, an s-maxage, or a CDN rule that overrides the origin. Fix: private on anything user-specific.

    In practice: caching for an online store's front end

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common patterns, and every number in it is an illustrative assumption.

    The situation

    A mid-sized store has a React single-page front end, a Node.js API and a CDN in front of both, owned by a small platform team. The build produces app.js and app.css at fixed names. Years ago someone added a CDN rule: "cache everything for one hour". Support now hears that after releases some shoppers "see old prices" or "get a blank page until they clear the cache".

    Starting architecture

    Figure 5. The store before the fix: one blanket rule

    Store front end before the fix: one CDN rule caches everything for an hourShoppers' browsers to CDN; CDN to Store server (HTML, API); CDN to Static assets (bundles, images)Shoppers' browsersShoppers'browsersCDN: one rule, 1 hourCDNone rule, 1 hourStore server: HTML and APIStore serverHTML and APIStatic assets: app.js, imagesStatic assetsapp.js, imagesHTML, APIbundles, images

    How to read this: three kinds of response needing very different freshness pass through one rule, which overrides the origin's own headers, so the application cannot say what it wants.

    Options considered

    Option What it fixes Risk
    Shorten the blanket rule to a few minutes Narrows the stale window Bundles still unhashed, so old and new files can mix; lower hit ratio
    Purge the CDN on every deploy Fixes the CDN layer Does nothing for copies already in browsers
    Hashed assets, revalidated HTML, short-lived API Fixes browser and CDN layers Build change, keep old assets, needs a careful rollout and header tests

    The decision

    Field Decision record
    Context One CDN rule gives HTML, bundles and API data the same lifetime, and bundles are not versioned.
    Decision Hash JS, CSS and image names. Hashed files: max-age=31536000, immutable. HTML: no-cache with an ETag. Product API: max-age=60, s-maxage=300, stale-while-revalidate=60. Cart and account: private, no-store. Remove the CDN rule so origin headers are respected. Errors cached at most a few seconds.
    Consequences, good Returning shoppers rarely re-download code; a deploy reaches everyone on their next page load; stale prices are bounded by the API lifetime.
    Consequences, bad The origin must keep previous releases' assets; header policy becomes code to review; HTML adds a revalidation round trip per navigation.
    Revisit when The CDN hit ratio for the API drops sharply, or stale-page reports continue a week after rollout.

    Rollout and verification

    Roll out in stages: ship hashed file names with the old rule still in place; publish the new headers for hashed files; switch HTML to no-cache with an ETag; remove the blanket rule. At each step run curl -sI against the CDN URL, not only the origin, and watch the hit ratio per path group, the share of 304s on HTML, script errors and 404s on /assets/. Add a pipeline test that fails the build if the Cache-Control of one hashed file, the HTML and one API URL differs from the expected value; header policy drifts silently otherwise.

    The 2 a.m. failure

    A release ships at 02:00. A clean-up job written to save storage deletes the previous release's bundles immediately. One legacy HTML path, the landing page, still carries an s-maxage from the old rule and sits in the CDN for up to an hour.

    Figure 6. What the browser sees after the clean-up

    sequenceDiagram accTitle: Cached HTML references a deleted bundle accDescr: A browser asks the CDN for the landing page. The CDN serves an old cached copy, which names a bundle the clean-up job deleted. The browser requests that bundle, the origin answers 404, and the page stays blank. participant B as Browser participant C as CDN participant O as Origin Note over O: deploy deletes app.3f9c.js B->>C: GET /landing C-->>B: cached HTML (Age: 1800), names app.3f9c.js B->>C: GET /assets/app.3f9c.js C->>O: miss, fetch O-->>C: 404 C-->>B: 404, page stays blank

    How to read this: the failure needs two mistakes at once, a cached HTML page and a deleted asset. Either alone is survivable.

    The alert is "script load errors up and 404s on /assets/ rising right after a release". First three checks: request the landing page through the CDN and read Cache-Control and Age; compare the bundle name in that HTML with the files on the origin; list the paths still matched by the old CDN rule. Mitigation: purge the affected HTML paths and redeploy the previous bundles. Follow-ups: retain the last few releases' assets, delete the legacy rule, and keep the header test.

    Architect's notes

    • Decide freshness per response type first. Hashed assets, HTML, public data and personal data are four policies; one rule for all is the root of most caching incidents.
    • Treat headers as an interface. Cache-Control is a contract with every cache downstream. Review it like an API and test it in CI.
    • Know which layer you are debugging. Browser, CDN and service worker cache independently; read Age and the response's Cache-Control first.
    • Defer extensions. Add stale-while-revalidate or stale-if-error when a measured problem calls for them, after confirming support.
    • Measure. CDN hit ratio, the share of 304s on HTML, repeat-visit transfer size and error rates after deploys; Loading Performance and Core Web Vitals covers wider measurement.

    Common mistakes

    • Reading no-cache as "do not cache". It means "store, but revalidate". Use no-store to prevent storage.
    • Long lifetimes on HTML, or on URLs that never change name.
    • Relying on heuristic freshness, which is each browser's guess.
    • A timestamp or build number instead of a content hash, which defeats reuse.
    • Blanket headers on every response, errors included.
    • Testing only with a hard reload or "Disable cache".

    Key takeaways

    • A cached response is fresh (reuse, no request) or stale (revalidate or refetch). Lifetime comes from s-maxage, then max-age, then Expires.
    • Validators (ETag, Last-Modified) make revalidation cheap; a 304 saves bandwidth but still costs a round trip.
    • no-cache means "revalidate before reuse"; no-store means "do not keep it".
    • immutable (RFC 8246), stale-while-revalidate and stale-if-error (RFC 5861) are extensions: check browser and CDN support.
    • Fingerprint file names, give hashed files a long lifetime and immutable, serve HTML with no-cache and an ETag, and keep old assets until no cached HTML can name them.
    • private keeps responses out of shared caches; s-maxage is read only by shared caches.
    • A hard reload hides real behaviour; test as a returning user.
    • The header sets here are example policies; derive yours from how stale each response may be.

    Practice exercises

    Exercise 1 — Starter (30 min): In DevTools on any site, find one script, one image and the HTML document. Write down their Cache-Control, ETag or Last-Modified, and Age. For each, say which freshness rule applies and whether the name suggests fingerprinting.

    Exercise 2 — Intermediate (90 min): Write a small local server with a hashed asset, an HTML page and a JSON API, each with a policy from the decision table and an ETag on the HTML and API. Use curl -sI with and without If-None-Match to confirm you get 304 where expected, then observe a normal reload and a hard reload in the browser.

    Exercise 3 — Advanced (half day): Reproduce the 2 a.m. failure locally: serve HTML with a shared-cache lifetime, change the bundle name, delete the old file, and watch a browser holding the old HTML fail. Fix it two ways (headers, and retaining old assets) and say which you would ship first and why.

    In this course:

    Elsewhere on the site:

    Official documentation

    Enroll to keep reading

    Lesson 6 of 8 · Frontend System Design Foundations: How the Web Runs

    Learn how freshness, validation and Cache-Control work in the browser cache, and choose header policies for hashed assets, HTML pages and APIs.

    In the rest of this lesson

    • The Cache-Control directives you will actually use
    • Browser cache versus shared cache
    • The standard strategy: fingerprinted assets, revalidated HTML
    • What to cache for each kind of response
    • Reload, hard reload and "Disable cache"
    • Service Workers and the Cache API: a different layer

    Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments