🎉 Premium Proxies · 3-Day Free TrialClaim Now →
Proxy Basic

How Proxy Caching Works: Forward Proxy Cache Explained

How proxy caching works: Cache-Control and ETag revalidation, why an HTTPS CONNECT tunnel cannot be cached, and how a stale hit corrupts scraped data.

S SparkProxy 0 21 min read
Share
How Proxy Caching Works: Forward Proxy Cache Explained

Proxy caching is a forward proxy storing a copy of an upstream HTTP response and replaying it to the next client that requests the same URL, under the freshness and revalidation rules in RFC 9111.

It was one of the founding reasons proxies existed, and on the modern web it is close to dead: an HTTPS CONNECT tunnel is an opaque byte pipe, and a proxy cannot cache bytes it cannot parse. This guide covers the freshness rules that decide what gets stored, why the tunnel kills the model, how a shared cache leaks and gets poisoned, and the stale hit that quietly ruins scraped data.

What Proxy Caching Actually Is

A caching forward proxy sits between a group of clients and the internet. When a client asks for a resource, the proxy checks whether it holds a usable copy. If it does, it answers from disk or memory and never touches the origin. Otherwise it fetches the resource, hands it over, and keeps a copy for whoever asks next.

Cache miss, then cache hit

  client A --GET /pricing.css--> [ proxy ] --GET /pricing.css--> [ origin ]
                                     |   stores response  <--200 + Cache-Control--
           <--------200-----------   |
                                     |
  client B --GET /pricing.css--> [ proxy ]          (origin never contacted)
           <---200, Age: 412------   |

Two things make this legal rather than reckless. RFC 9111, the caching specification published in June 2022 that obsoleted RFC 7234, defines exactly when a stored response may be reused. And the origin states its terms in response headers, which a well-behaved proxy obeys.

The economics that built this model no longer hold. Bandwidth is cheap, content is personalized, and, as the CONNECT tunnel section explains, almost none of it is visible to the proxy anyway. Caching forward proxies survive in three niches: corporate egress gateways, ISP transparent caches in bandwidth-constrained markets, and package mirrors where traffic is plain HTTP by design.

The Cache Decision, Step by Step

Every response runs the same sequence, and the order matters: most surprising cache behavior comes from a step you did not know existed.

Step 1: Is it storable? RFC 9111 §3 forbids a shared cache from storing a response carrying no-store or private, or one answering a request that carried Authorization unless the response opts in with public, s-maxage or must-revalidate. GET and HEAD are the realistic cacheable methods.

Step 2: What is the cache key? The primary key is the method plus the full target URI, including scheme, host, port and query string. A secondary key comes from Vary, naming request headers that must also match. Vary: User-Agent gives one entry per user agent string, which is why a scraper rotating hundreds of them through a shared cache gets a hit rate near zero.

Step 3: Is it fresh? s-maxage first, then max-age, then Expires minus Date. With none of them present the cache is allowed to guess.

Step 4: Heuristic freshness. That guess is where the trouble starts. RFC 9111 §4.2.2 lets a cache invent a lifetime, and the near-universal implementation is a fraction of the time since Last-Modified. Squid's default squid.conf ships refresh_pattern . 0 20% 4320: 20 percent of the document age, capped at three days. A page last modified 10 days ago gets a two-day freshness lifetime no header ever requested. The cache decided on its own.

Step 5: Stale, so revalidate. Past its lifetime the entry is stale but not deleted. The cache sends a conditional request, and a 304 Not Modified refreshes the timer without moving the body. See conditional revalidation below.

Status codes matter at step 1 too. RFC 9110 §15.1 makes these heuristically cacheable by default: 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414 and 501. Notice 404. A product page that returned "not found" during a five-minute deploy can sit in a shared cache as fresh long after the product came back.

Free trial

Scraping at scale? Skip the blocks.

Fast, unblockable datacentre proxies with unlimited bandwidth.

The Cache-Control Directives That Decide Everything

Cache-Control is the control surface. It appears in both requests and responses, and the same token can mean different things in each direction.

Response directiveEffect on a shared proxy cache
`max-age=N`Fresh for N seconds. Applies to every cache.
`s-maxage=N`Fresh for N seconds in **shared** caches only. Overrides `max-age` there.
`no-cache`May be stored. Must be revalidated with the origin before every reuse.
`no-store`Must not be written to disk or memory at all.
`private`Must not be stored by a shared cache. A browser cache may store it.
`public`Storable even when normal rules (such as `Authorization`) would forbid it.
`must-revalidate`Once stale, must not be served without successful revalidation.
`proxy-revalidate`Same rule, binding shared caches only.
`immutable`The body will not change during its freshness lifetime, so skip revalidation on reload.
`stale-while-revalidate=N`May serve a stale copy for N seconds while refreshing in the background ([RFC 5861](https://www.rfc-editor.org/rfc/rfc5861.html)).

The pair that trips up almost everyone is no-cache versus no-store.

no-cache does not mean "do not cache". It means "keep this copy, but ask me before you use it." The response gets written to disk and every later request triggers a conditional revalidation. It is a bandwidth optimization with a correctness guarantee, not a prohibition.

no-store is the prohibition. Nothing is written anywhere. Use it for anything that must not survive on shared infrastructure, and pair it with private for defense in depth against caches that implement one and not the other.

HTTP/1.1 200 OK
Date: Tue, 18 Aug 2026 09:14:02 GMT
Content-Type: text/html; charset=utf-8
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "9f2c1a4b-2ee0"
Last-Modified: Mon, 17 Aug 2026 22:03:11 GMT
Vary: Accept-Encoding

A browser reuses that for 60 seconds and a shared proxy for 600. Both get a validator, so revalidation costs a header exchange instead of a body, and Vary splits the entry by content encoding so gzip and Brotli clients never collide.

ETag, Last-Modified, and Conditional Revalidation

Validators are how a stale entry becomes fresh again without moving the payload. Last-Modified is a timestamp with one-second resolution, cheap to produce from a filesystem mtime and useless for resources that change more than once per second. ETag, defined in RFC 9110 §8.8.3, is an opaque validator the origin computes however it likes: a content hash, a row version, a build ID. Strong ETags ("abc") assert byte-for-byte equality; weak ETags (W/"abc") assert only semantic equivalence, enough for caching but not for range requests.

The cache holds a stale entry and asks whether its copy still stands:

GET /products/sp-1042 HTTP/1.1
Host: shop.sparkproxy.io
If-None-Match: "9f2c1a4b-2ee0"
If-Modified-Since: Mon, 17 Aug 2026 22:03:11 GMT

The origin compares and answers with a body-free 304:

HTTP/1.1 304 Not Modified
Date: Tue, 18 Aug 2026 11:40:55 GMT
ETag: "9f2c1a4b-2ee0"
Cache-Control: public, max-age=60, s-maxage=600

The cache resets the freshness clock from the new Date and serves the stored body. The client sees a 200 with the full payload and never learns a 304 happened. That invisibility is what makes caching hard to debug from outside.

When both are present, If-None-Match wins: RFC 9110 §13.1.3 requires a recipient to ignore If-Modified-Since alongside it. So an origin that rotates ETags on every deploy, a common side effect of embedding a build hash, invalidates every cached entry globally even though the bytes are unchanged.

A detail that costs people hours: many origins behind compression middleware rewrite the ETag when they gzip, appending a suffix such as -gzip. Apache's mod_deflate has done this for years. A cache holding the identity representation then revalidates asking for Accept-Encoding: gzip, gets a non-matching ETag, and pulls the whole body again. The cache looks broken. It is behaving correctly on inconsistent input.

Why an HTTPS CONNECT Tunnel Cannot Be Cached

Most articles on proxy caching skip this section. It is the one that matters most.

When a client wants HTTPS through a forward proxy, it does not send a request the proxy can read. It sends CONNECT, defined in RFC 9110 §9.3.6, asking the proxy to open a raw TCP connection to a host and port and then step out of the way:

CONNECT shop.sparkproxy.io:443 HTTP/1.1
Host: shop.sparkproxy.io:443
Proxy-Authorization: Basic c3BfdXNlcjpzcF9wYXNz
HTTP/1.1 200 Connection Established

After that 200, everything on the socket is TLS. The client and the origin handshake through the proxy and exchange encrypted records. Look at what the proxy has left:

What caching needsPlain HTTP requestHTTPS via CONNECT
Request methodVisible (`GET`)Not visible
Full target URI, the cache keyVisibleOnly `host:port` from the CONNECT line
Response status codeVisible (`200`)Not visible
`Cache-Control`, `ETag`, `Vary`VisibleNot visible
Response bodyVisibleEncrypted bytes
Server nameFrom the `Host` headerSNI in the ClientHello, plaintext unless ECH is in use

The cache key alone ends the argument. A cache maps a request to a response, keyed on the method plus the full URI. Through a tunnel the proxy knows shop.sparkproxy.io:443 and nothing else, so every path, every query string and every product page collapses into one indistinguishable key. There is no correct entry to store and none to serve.

The proxy also cannot tell where one response ends and the next begins. HTTP/2 multiplexes many streams over that one TLS connection, and HTTP/3 replaces TCP with QUIC over UDP, which a CONNECT tunnel does not even carry. A byte count and a duration are the whole of its visibility.

The only way around this is interception, and interception is a different product. An intercepting proxy, also called TLS inspection or SSL bumping, terminates the client's TLS session with a certificate minted from a CA it controls, decrypts, caches, then opens a second TLS session to the origin. Squid calls this ssl_bump. It works, but the costs are concrete:

  • Every client must trust your CA. That means device management, and anything you do not control or that pins certificates breaks.
  • The TLS fingerprint changes. The origin sees the interceptor's cipher ordering, extension order and ALPN offer, not the client's. Anti-bot systems fingerprint exactly this, so an intercepting cache in a collection pipeline is a detection surface, not an optimization. See TLS fingerprinting.
  • You hold every secret in plaintext. Session cookies, bearer tokens and form posts for every user pass through one box in the clear.
  • Encrypted Client Hello removes the last hint. As ECH deployment grows, even the SNI hostname stops being visible.

Google's HTTPS Transparency Report tracks this as a weekly series, and the share of Chrome page loads over HTTPS on Windows has sat above 95 percent since 2023 (checked August 2026). Without interception, then, a forward proxy cache reaches single-digit percentages of what it carries. The rest is opaque tunnel it can only count bytes through. For the mechanics, see what an HTTP tunnel is.

This is also why commercial proxy networks do not cache. A forward proxy sold for data collection is selling an exit IP, and its traffic is HTTPS end to end. There is nothing to store even if storing were desirable.

Shared Caches, Private Caches, and What Leaks

RFC 9111 §1.1 splits caches in two, and that distinction drives every safety rule in the specification. A private cache serves one user, your browser's disk cache being the canonical example, so it may store personalized responses. A shared cache serves many: a corporate proxy, an ISP transparent cache, a CDN edge node. It must not store anything user-specific, because an entry keyed on a URL gets replayed to whoever requests that URL next.

The private directive is how an origin says "browser yes, proxy no". It is a correctness signal, not a security control, and treating it as one is a recognizable class of incident:

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: private, max-age=300
Set-Cookie: session=8f21b0...; HttpOnly; Secure

<h1>Welcome back, Dana</h1>

A shared cache that respects private stores nothing. A misconfigured one, or one whose operator added a blanket refresh_pattern override, hands the next visitor to /dashboard Dana's page and possibly Dana's Set-Cookie. The defense that holds is no-store on anything authenticated, plus Vary: Cookie where a session legitimately changes the page.

The Authorization header gets special treatment for the same reason: RFC 9111 §3.5 bars a shared cache from reusing a response to a request that carried it. Cookies get no such protection, because HTTP has no idea a cookie means "logged in". That asymmetry, a spec guarding the one auth header most applications stopped using for sessions, is the most common cause of cross-user leaks.

Cache Poisoning on a Shared Cache

A shared cache multiplies the reach of anyone who can get one bad response stored in it. Write once, serve to everyone. Two mechanisms dominate.

Unkeyed input. The cache key is the method plus the URI plus whatever Vary names. Everything else that shapes the response is unkeyed. If the app reflects X-Forwarded-Host into an absolute script URL, one request carrying X-Forwarded-Host: evil.example makes the origin build a page pointing