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.

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.
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 directive | Effect 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 needs | Plain HTTP request | HTTPS via CONNECT |
|---|---|---|
| Request method | Visible (`GET`) | Not visible |
| Full target URI, the cache key | Visible | Only `host:port` from the CONNECT line |
| Response status code | Visible (`200`) | Not visible |
| `Cache-Control`, `ETag`, `Vary` | Visible | Not visible |
| Response body | Visible | Encrypted bytes |
| Server name | From the `Host` header | SNI 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.
The Scraper Problem: A Stale Hit Corrupts a Price Series
Here is the failure that costs real money, and it produces no errors at all.
You run a daily price scrape. The target product page carries no Cache-Control and no Expires, common for server-rendered catalog pages, and its Last-Modified is 10 days old. A shared cache in your path applies the standard 20 percent heuristic and assigns a two-day freshness lifetime.
Four days of collection then look like this:
| Day | Cache state | Price you record | True price on site |
|---|---|---|---|
| Monday | Miss, response stored | 49.99 | 49.99 |
| Tuesday | Hit, entry still fresh | 49.99 | 39.99, the cut lands |
| Wednesday | Hit, entry still fresh | 49.99 | 39.99 |
| Thursday | Stale, revalidated | 39.99 | 39.99 |
Your dataset now says the price cut happened on Thursday. It happened on Tuesday, and every downstream artifact inherits the error:
- Change-detection alerts fire two days late, so a repricing decision gets made against a competitor position that no longer exists.
- The time series has a flat segment a smoothing model reads as genuine price stability.
- MAP monitoring reports a violation window off by 48 hours, the discrepancy that collapses a claim against a reseller.
- Backfilling is impossible. The correct Tuesday value no longer exists anywhere you can reach.
Two properties make this worse than an outage. The response is a valid 200 with a complete body, so no retry triggers and no parser raises. And every stale hit is fast, so latency dashboards look their best on the days the data is wrong.
Detection is cheap. Age, which any conforming cache sets, reports how many seconds the body has been held. Log it with two companions and refuse to trust a row that fails them:
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def cache_verdict(resp, max_age_seconds=60):
"""Return (trustworthy, reason) for a scraped response."""
age = int(resp.headers.get("Age", 0))
if age > max_age_seconds:
return False, f"served from cache, Age={age}s"
xcache = resp.headers.get("X-Cache", "")
if "HIT" in xcache.upper():
return False, f"X-Cache reports a hit: {xcache}"
date_hdr = resp.headers.get("Date")
if date_hdr:
origin_time = parsedate_to_datetime(date_hdr)
skew = (datetime.now(timezone.utc) - origin_time).total_seconds()
if skew > max_age_seconds:
return False, f"Date header is {skew:.0f}s old"
return True, "fresh"
Age is standardized in RFC 9111 §5.1. X-Cache is not, but Squid, Varnish and every major CDN emit it as HIT or MISS. A Date header trailing your clock by more than the round trip means the body predates your request, the signature of a hit even when Age is missing.
For request-side control you get two levers, and neither is guaranteed:
# Request directives: shared caches SHOULD honor these. Transparent caches often do not.
curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" \
"https://shop.sparkproxy.io/products/sp-1042"
# The lever that always works: change the cache key.
curl "https://shop.sparkproxy.io/products/sp-1042?_cb=$(date +%s)"
Cache-Control: no-cache as a request directive asks caches to revalidate first, and Pragma: no-cache is the HTTP/1.0 equivalent kept alive for old proxies. Both are requests, not commands. A cache-busting query parameter is not a request at all, it is a different cache key, which is why it works on caches that ignore everything else. It also defeats the origin's own CDN, so expect slower responses and more attention from rate limiting, a tradeoff our guide to ethical scraping and rate limiting covers.
Forward Proxy Caching Is Not CDN Caching
The two get discussed as one topic and they are opposite jobs. A forward cache works for the client and stores whatever the client happens to request. A reverse cache works for the origin and stores what the origin published.
| Forward proxy cache | Reverse proxy cache / CDN | |
|---|---|---|
| Acts on behalf of | The client | The origin server |
| Content scope | Anything any client requests | One origin's own content |
| TLS position | Outside the session, so it sees nothing | Terminates TLS as the origin, so it sees everything |
| Holds a certificate for the site | No | Yes |
| Cache key control | None, it consumes whatever headers arrive | Full, the operator defines keys and TTLs |
| Purge on publish | Impossible | Standard, by tag, path or wildcard |
| Practical hit rate today | Very low, most traffic is tunneled | Very high, often above 90 percent for static assets |
| Directive that targets it | `private` excludes it, `s-maxage` binds it | `s-maxage`, plus vendor headers such as `Surrogate-Control` |
The decisive row is TLS position. A CDN edge holds a valid certificate for the site it serves, so encryption terminates there legitimately and it reads everything. A forward proxy holds no certificate for anyone and sits outside the session by construction. That one structural fact is why reverse caching thrived while forward caching withered. For the contrast in full, see what a reverse proxy is.
One middle case matters. A transparent proxy intercepts traffic with no client configuration, and ISPs historically ran transparent caches on port 80. Those still exist in some networks, and they are the caches least likely to be documented or to honor request directives. They are why your Age check belongs in production.
Controlling Caching in Practice
If you operate an origin: send explicit Cache-Control on everything, because a missing header hands the decision to a heuristic you did not choose. Use no-store on authenticated responses with private as a second layer, s-maxage to give shared caches their own lifetime, and an ETag wherever a 304 saves real bandwidth. Never send both Content-Length and Transfer-Encoding, which closes the smuggling door from cache poisoning.
If you collect data, the rule is shorter: get shared caches out of the path, then verify that you did. Requests through the SparkProxy Scraping API leave the network per job rather than from a stored copy, and the JSON envelope returns the numbers that expose an unexpected cache anywhere in the path:
import requests
resp = requests.get(
"https://scrape.sparkproxy.io/api/v1",
headers={"X-API-Key": "YOUR_API_KEY"},
params={
"url": "https://shop.sparkproxy.io/products/sp-1042",
"render_js": "true",
"premium_proxy": "true",
"country_code": "de",
"json_response": "true",
},
timeout=60,
)
job = resp.json()
print(job["status_code"], job["duration_ms"], job["credits_used"])
With json_response=true the envelope carries job_id, status_code, duration_ms, credits_used and a meta object with the page title and description. Watch duration_ms across a run. A rendered product page that normally takes 3000 to 5000 ms and suddenly returns in 180 ms did not render faster, it was answered by something holding a copy. Cheapest cache alarm you can build.
For targets behind aggressively cached edges, pair a cache-busting parameter with geo-targeted routing:
curl -G "https://scrape.sparkproxy.io/api/v1" \
-H "X-API-Key: YOUR_API_KEY" \
--data-urlencode "url=https://shop.sparkproxy.io/products/sp-1042?_cb=1755511200" \
--data-urlencode "country_code=de" \
--data-urlencode "premium_proxy=true" \
--data-urlencode "render_js=true" \
--data-urlencode "json_response=true"
country_code takes an ISO 3166-1 alpha-2 value and costs 5 extra credits, premium_proxy routes through the residential pool, and a plain fetch with render_js=false costs 1 credit against 5 for a rendered page. Check the Scraping API docs for the current parameter list.
One habit worth adopting: store Age, Date and X-Cache alongside every scraped record, not just the parsed fields. When somebody questions a price movement six months from now, those three columns are the difference between proving a measurement and guessing at it.
Frequently asked questions
FAQ
Proxy caching is a forward proxy storing a copy of an upstream HTTP response and serving it to later clients requesting the same URL, following the freshness and revalidation rules in RFC 9111. It cuts origin load and bandwidth, and it only works on traffic the proxy can actually read.
Not through a normal CONNECT tunnel. After the proxy returns 200 Connection Established the traffic is encrypted end to end, so it sees no URL path, no status code and no Cache-Control header, leaving it without a usable cache key. Caching HTTPS requires an intercepting proxy that terminates TLS with its own CA, which changes the client's TLS fingerprint and exposes every secret on the wire.
no-cache allows the response to be stored but requires revalidation with the origin before every reuse. no-store forbids storage entirely. To keep a response off shared infrastructure, no-store is the directive you want, ideally combined with private.
private tells shared caches, including forward proxies and CDNs, not to store the response, while still allowing a single-user browser cache to keep it. It is a correctness signal rather than a security control, so authenticated responses should carry no-store as well.
It uses s-maxage first, then max-age, then Expires minus Date. With none of those present it may apply heuristic freshness, commonly a percentage of the time since Last-Modified, such as Squid's default of 20 percent capped at three days.
Check the Age response header, which any conforming cache sets to the seconds the response has been held, and the non-standard X-Cache header, which Squid, Varnish and most CDNs set to HIT or MISS. A Date well older than your request time, or a response far faster than the target's usual latency, points at the same thing.
Get 50% off your first month
Premium datacentre proxies with unlimited bandwidth. Use the code at checkout.
Offer ends soon — claim it before it's gone
Related articles

What Is MTU and MSS Clamping in Proxy Connections
Small requests work, large ones hang forever? Learn MTU vs MSS, PMTUD black holes, and how MSS clamping fixes stalled proxy and tunnel connections.

The HTTP CONNECT Method Explained
The HTTP CONNECT method at wire level: authority-form request lines, 200 Connection Established, 407 and 502 debugging, and why HTTPS resists inspection.

BGP and RIR IP Allocations: Why Proxy IP Origin Matters
An RIR IP allocation says who holds a proxy IP, BGP says where it is routed. Learn to audit both with RDAP, RPKI and geofeeds before you buy proxies.
