Cache Storage Is Not the HTTP Representation
Fresh Cloudflare cache work shows why storage encoding, selected representation, wire content, validators, and ranges must stay separate.
Cloudflare published a cache experiment on 1 September that changes bytes without changing the asset a client receives. Its Cache Transcoding prototype takes eligible uncompressed text responses, encodes them with Zstandard before writing them to cache, keeps that form while the object moves through cache tiers, and decodes it on the client-facing path.
Five days earlier, Cloudflare described a different cache representation change inside its DNS platform. Immutable lists became fixed-size structures, repeated fields were inferred from the cache key, and parsed record variants became a compact byte buffer while enough metadata remained structured to answer queries correctly.
The HTTP cache prototype and DNS cache redesign operate at different protocol layers. Their shared lesson is nevertheless useful for anyone building a CDN, fetch service, image pipeline, API cache, crawler, or local web utility: the form that is efficient to retain is not automatically the form that identifies the object or belongs on the wire.
The repeated angle to avoid
The ten most recent posts here covered product-variant graphs, htmx state provenance, Shopify finance clocks, verified-bot policy joins, AI-catalog conformance, security-dashboard denominators, chat-adapter semantics, agent compute planes, security treatment states, and soft-navigation measurement. The closest older article explained how Vary controls cache variants.
The weak version would return to the old X needs Y formula: cache compression needs testing. It would also repeat the earlier advice to bound Vary cardinality.
The narrower thesis is that cache selection and cache storage solve different problems. A cache key decides which stored response may satisfy a request. A storage codec decides how one selected object occupies memory, disk, or an internal transfer. HTTP content coding decides how representation data is encoded for a recipient. A validator decides whether two observations are the same representation version. A byte range addresses positions in selected representation data.
Collapsing those into one “compressed object” field creates bugs that a successful decompression test cannot find. Keeping them separate enables a storage migration that increases cache density without changing public ETags, range offsets, conditional requests, downstream content negotiation, or purge behaviour.
Fresh evidence and background serve different jobs
| Source | Freshness | What it contributes |
|---|---|---|
| Cloudflare Cache Transcoding | 1 September 2026 | A prototype that privately stores eligible identity responses as zstd, preserves original length metadata, carries the encoded form between cache tiers, and decodes at the client-facing hop |
| Cloudflare DNS cache memory redesign | 27 August 2026 | Fresh evidence that immutable cache entries can use compact internal structures and byte buffers while reconstructing protocol-correct responses from retained metadata |
| HTTP Semantics, RFC 9110 | Background standard | Representation data and metadata, content codings, strong and weak validators, and byte-range semantics |
| HTTP Caching, RFC 9111 | Background standard | Cache keys, stored-response selection, content negotiation, and Vary matching |
| HTTP Digest Fields, RFC 9530 | Background standard | The distinction between a digest of actual message content and a digest of the entire selected representation data |
| Zstandard format, RFC 8878 | Background format reference | Lossless zstd frames, content-coding registration, and the format's lack of general random access |
The two Cloudflare articles are the fresh primary evidence. The RFCs define the public contracts that an implementation must preserve; they are not evidence that Cloudflare has released Cache Transcoding as a customer feature.
That distinction matters. The 1 September article repeatedly calls the work a prototype and says broader content, size, range, precompressed-response, and downstream tests remain future work. Its measured compression ratio came from deliberately compressible test assets, not a representative promise for every website.
One cache entry can have four byte identities
“Object bytes” is too ambiguous for a cache design. Use four layers.
Resource and selected representation
A resource is what a URI identifies. A request selects a representation according to method, target URI, request headers, origin logic, and any intermediary rules. Language, media type, image format, or client-facing content coding can make two responses distinct selected representations of the same resource.
RFC 9111 says a cache key contains at least the request method and target URI. A cache can add request-header dimensions nominated by Vary so that, for example, a Brotli-capable request does not receive a representation selected for an incompatible client.
This is the selection layer. It answers:
May this stored response satisfy this request?
It does not answer how the cache should lay out the selected object on disk.
Representation data and metadata
RFC 9110 describes representation data through a two-layer model: media type data is transformed by any declared content codings. Content-Type, Content-Encoding, Content-Length, validators, language, and other fields tell a recipient what the bytes mean.
If an origin returns an identity-encoded JSON document, those public bytes and metadata are the contract seen by the cache. If the origin returns a gzip representation with Content-Encoding: gzip, that coding is part of the selected representation's metadata. It is not merely an invisible storage optimisation.
Private storage representation
A cache can create an implementation-only form that never appears as HTTP representation metadata. Cloudflare's prototype does exactly that: an eligible identity response becomes zstd inside the cache, an internal marker records the storage encoding, and original length metadata is retained. The object is decoded before continuing down the client-facing path.
Conceptually:
origin-selected identity bytes
-> internal zstd encode
-> stored bytes and tier-transfer bytes
-> internal zstd decode
-> original identity bytes
-> any separate downstream HTTP handling
The internal zstd marker must not leak into Content-Encoding merely because zstd bytes exist on disk. Conversely, a downstream Content-Encoding: zstd response would be a public content-coding decision with negotiation, metadata, validator, and compatibility consequences.
Message content on one hop
The bytes actually enclosed in an HTTP message can differ from the complete selected representation. A HEAD response has representation metadata without the corresponding GET body. A 206 Partial Content response contains a selected range. Transfer framing can also change how content travels without changing the representation.
RFC 9530 makes the distinction testable. Content-Digest covers actual message content, while Repr-Digest covers the entire selected representation data. They can legitimately describe different byte sets for a partial response.
A private cache codec introduces another byte set, but it does not become either digest automatically. If an operator records a storage checksum, name it as such. Do not place a digest of private compressed bytes into a field that claims to describe public message content or selected representation data.
The fresh eligibility list is a correctness boundary
Cloudflare's prototype does not compress every cacheable response. It selects a conservative class:
- status is
200 OK; Content-Encodingis unset;Content-Typeidentifies compressible text;Content-Lengthis known and at least 4 KiB;- the request is not a range or internal slice request;
- upstream compression and precompressed content are absent;
- the body is not an unknown-length stream or already-compressed binary media.
That list is not just performance tuning. Each exclusion avoids a semantic ambiguity or a poor cost trade-off.
A precompressed response already has a public or source-specific byte representation. Encoding it again can waste CPU and make metadata ownership unclear. Images, video, and fonts usually offer little additional compression. Unknown-length streams cannot be admitted by the same size threshold before storage. Internal slices and HTTP ranges make offsets significant. Tiny objects pay fixed framing, allocation, and CPU costs for little capacity gain.
The prototype's 4 KiB threshold removed many small requests while excluding only about one percent of otherwise eligible bytes in Cloudflare's sample. That is a workload result, not a universal constant. A JSON API with millions of 3 KiB objects, a source-code cache, and a large-document CDN have different object distributions and reuse patterns.
The implementation rule is to store the eligibility reason and policy version with the object, not only compressed=true. Otherwise a later rollout cannot explain why two apparently similar objects followed different paths.
Byte ranges expose the hidden representation
Ranges are where an internal codec most visibly collides with public semantics.
RFC 9110 defines Range as a request for subranges of the selected representation data. If a client asks for bytes 1000-1999, those offsets refer to the public selected representation—not arbitrary positions inside a private zstd frame.
Three naive implementations fail:
- Slice stored bytes, then decode. The selected compressed segment might not be independently decodable and does not map to the requested public offsets.
- Decode only until the requested end. This can be correct but makes late ranges increasingly expensive and needs careful handling of validators and
Content-Range. - Return stored offsets as public offsets. The response is simply the wrong data, even if its length is exactly 1,000 bytes.
RFC 8878 says its data format does not attempt to provide random access. Independent frames can help a purpose-built indexed layout, but the HTTP layer still needs a mapping from public byte positions to storage chunks and must prove that the returned range belongs to the selected representation.
Cloudflare's prototype excludes range requests and slice subrequests. That is the right shape for an early rollout: preserve the established path until the storage format has an explicit range design.
For a custom cache, choose one policy per object class:
| Range policy | Suitable when | Required evidence |
|---|---|---|
| Bypass internal transcoding | Ranges are rare or correctness matters more than capacity | Range and full-body responses share validators and public bytes |
| Decode full object, then slice | Objects are bounded and range traffic is low | CPU, memory, latency, and abuse limits remain acceptable |
| Chunk and index the storage form | Large objects and ranges justify complexity | Stable public-to-storage offset map, independent chunk integrity, and complete-range reconstruction tests |
| Keep a separate range-friendly representation | Full-body reuse and random access are both frequent | Invalidation, validator, and purge logic update both forms atomically |
A 206 response test must compare bytes with the same interval from a decoded 200 response. Status and length alone prove nothing.
Validators must follow public meaning, not disk layout
A strong validator such as an ETag is useful only when it remains tied to the representation data observable in a successful GET. RFC 9110 requires a strong validator to change whenever an observable change occurs to that representation data.
That creates a migration rule:
Re-encoding identical public bytes for private storage should not create a new public representation version merely because the disk bytes changed.
If a deployment changes zstd level 3 to level 6, repacks chunks, adds a dictionary, or changes an internal header while decoded bytes and public metadata remain identical, a validator derived from storage bytes will churn. Conditional requests miss, downstream caches refill, and clients redownload unchanged assets.
The opposite error is worse. If decoded public bytes or semantically significant representation metadata changes while a stale ETag remains, a 304 Not Modified can preserve an obsolete response.
Keep several identifiers with explicit scopes:
cache_object_identity:
resource_key: GET https://example.com/assets/app.js
selection_key:
vary_accept_encoding: identity
deployment: release_842
public_representation:
strong_etag: origin-or-canonical-validator
content_type: application/javascript
content_encoding: identity
decoded_length: 184210
private_storage:
codec: zstd
codec_level: 3
storage_format_version: cache-zstd-v1
stored_length: measured-not-guessed
storage_checksum: internal-only
lifecycle:
origin_revision: build_842
policy_revision: transcode-policy-v1
created_at: recorded_timestamp
purge_tags:
- asset-app-js
The numbers and field names are an implementation sketch, not Cloudflare's schema. Its value is the scope boundary: storage-format changes can invalidate or rewrite the private object without claiming that the origin resource changed.
When client-facing content coding varies, the validator strategy must also account for those representations. RFC 9110 includes an example in which gzip and identity variants use distinct entity tags. Do not assume one ETag can be copied across content-negotiated byte representations without reviewing whether it remains strong for each one.
Compact storage can become an origin-capacity control
The obvious benefit of smaller cache entries is a lower storage bill. The more important operational effect can be fewer evictions.
Cloudflare's Cache Transcoding test compressed its deliberately compressible assets by roughly 2.8 times. The article estimates that eligible text averaged about one-third of its original on-disk size in initial testing. More objects can therefore remain resident on the same hardware, and compressed objects use less bandwidth while moving between cache tiers.
The DNS cache work reaches the same capacity mechanism without HTTP compression. Five changes reduced benchmarked per-entry memory from 953 bytes to 420 bytes, cut per-entry allocation from about 1.1 KiB to 461 bytes, increased insert throughput by 43%, and reduced lookup latency by 19%. Production resident-memory reductions were smaller because the process contains more than the cache. Cloudflare says it plans to reinvest freed memory in more cache capacity, which can improve hit rates and reduce upstream DNS query volume.
For a web product, the causal chain is:
smaller eligible entries
-> more useful objects retained within the same capacity
-> fewer capacity evictions for that workload
-> potentially more cache hits
-> potentially fewer origin fills and less tier traffic
Every arrow is conditional. Larger capacity does not rescue an incorrect key, an uncacheable response, a too-short freshness lifetime, a purge storm, or a workload whose reuse distance exceeds the enlarged cache. Compression can also consume enough CPU to increase tail latency or reduce throughput before capacity becomes the bottleneck.
Measure at least:
- eligible, excluded, encoded, decoded, and failed object counts;
- original, stored, and client-transmitted bytes as separate metrics;
- encode CPU and latency on fills;
- decode CPU and latency on hits;
- hit ratio and eviction cause by object-size class;
- origin fills and tier transfers per stable traffic cohort;
- conditional hit, range, and purge outcomes;
- p50, p95, and p99 response latency during mixed hit and miss load.
Do not report reduced cache-tier bytes as reduced customer egress unless the client-facing bytes also changed. Do not report a corpus compression ratio as an origin-offload gain until hit and fill measurements show it.
Encoding once does not mean paying once
Cloudflare's prototype pays the zstd encoding cost when the object enters the cache, but decoding occurs on the serving path. The article says limiting transcoding to only popular objects did not cut CPU proportionally because every hit still required decoding.
That produces three workload classes:
| Workload | Likely pressure | Main question |
|---|---|---|
| High fill, low reuse | Encode cost and churn | Will the object survive long enough to repay the fill work? |
| Low fill, high reuse | Decode CPU and hit latency | Can serving capacity absorb decoding on every reuse? |
| Large cold tail under fixed storage | Eviction and capacity | Does smaller storage retain enough useful tail objects to reduce upstream fills? |
A system can move from disk-bound to CPU-bound after a successful storage optimisation. That is not necessarily a failure; it is a changed resource budget. Rollout limits should use both storage saved and serving CPU consumed.
A practical decision metric is not compression ratio alone:
net value per object class
= avoided storage pressure
+ avoided internal transfer
+ avoided origin fills from better retention
- encode CPU
- decode CPU
- added latency
- operational complexity and failure recovery
The terms need not be converted into one currency immediately. A scorecard that shows them separately is already safer than promoting the codec with a single “2.8x smaller” result.
A transition matrix for cache storage changes
Test paths, not only codecs.
| Transition | What must remain true | Failure signal |
|---|---|---|
| Origin miss to encoded store | Decoding reproduces origin bytes and metadata; one storage marker is written | Digest mismatch, truncated body, wrong original length |
| Local cache hit | Public status, headers, body, validator, and timing remain valid | Hit succeeds but bytes or ETag differ from the miss path |
| Upper-tier hit to lower-tier fill | Internal encoding is recognized and not applied twice | Double-compression, unknown marker, tier-only corruption |
| Rolling format upgrade | Old and new workers can read or deliberately refill supported versions | One deployment cohort treats stored bytes as identity |
| Conditional GET | If-None-Match and If-Modified-Since decisions use public representation state |
Storage rewrite causes a refill or false 304 |
HEAD request |
Metadata matches the corresponding GET representation | Internal compressed length leaks as public Content-Length |
| Range request | Returned bytes equal the requested interval of selected representation data | Stored offsets are returned or late ranges exhaust CPU |
| Content negotiation | Cache selection remains correct for Accept-Encoding, Accept, and language |
An incompatible client receives another variant |
| Purge or origin revision | Every private form of the obsolete public representation becomes unreachable | Old storage version survives under a secondary key |
| Decode or checksum failure | Object is quarantined and safely refetched, with loop protection | Corrupt entry generates repeated 500s or refill storms |
The most valuable fixture is one canonical byte corpus with awkward boundaries: smaller and larger than the threshold, empty and tiny bodies, multibyte text, incompressible text, already-compressed payloads, unknown lengths, ranges at start and end, conditional requests, and content-negotiated variants.
For an HTTP-facing cache, a compact smoke probe can compare public observations:
URL="https://example.com/assets/app.js"
curl -sS -D /tmp/full.headers -o /tmp/full.body "$URL"
curl -sS -D /tmp/range.headers -H 'Range: bytes=1000-1999' \
-o /tmp/range.body "$URL"
dd if=/tmp/full.body of=/tmp/expected-range.body \
bs=1 skip=1000 count=1000 status=none
cmp /tmp/range.body /tmp/expected-range.body
ETAG=$(awk 'BEGIN{IGNORECASE=1} /^etag:/{sub(/\r$/, ""); print substr($0, 7)}' \
/tmp/full.headers)
curl -sS -D - -o /dev/null -H "If-None-Match: $ETAG" "$URL"
Run it against cold miss, warm hit, each cache tier, and both sides of a storage-format rollout. Add Accept-Encoding variants only when the service deliberately exposes them. The probe verifies public bytes and conditional behaviour; internal metrics must separately prove which storage path ran.
Failure modes worth rehearsing
Internal zstd leaks into public metadata
A cache worker reads the storage marker and emits Content-Encoding: zstd without negotiating with the client. The bytes may be valid zstd, but the selected representation and cache-selection contract changed. Keep private codec metadata in a separate namespace and make the egress path construct public headers from selected-representation metadata.
Original length becomes stored length
The cache writes a 60 KiB compressed object for a 180 KiB identity response, then returns Content-Length: 61440 after decoding. Clients truncate, hang, or mis-handle persistent connections. Retain the correct public length independently and test HEAD against GET.
A format migration churns every ETag
The storage checksum is reused as the public validator. Changing compression level alters every checksum even though decoded assets are identical. Downstream caches and browsers redownload the site. Scope storage checksums internally; derive public validators from the selected public representation and its meaningful metadata.
The range path slices compressed storage
A 206 response has the expected status and byte count but contains positions from the zstd stream rather than the selected JavaScript file. Compare each returned range with the same slice of a verified full response, including suffix and open-ended ranges.
One cache tier encodes twice
An upper tier transfers an already encoded object to a lower tier that mistakes it for identity bytes. One decode leaves another compressed stream. Use a versioned internal marker, reject unknown versions, and test every miss/hit combination across tiers.
Capacity gains disappear into key fragmentation
Objects become smaller, but unbounded Vary values or accidental user-specific keys create more entries than the codec saves. Measure unique selection keys and variant count beside stored bytes. Storage compression cannot repair an uncontrolled cache identity model.
CPU becomes the new eviction policy
A high-hit text workload saves disk but decodes on every response. Tail latency rises and workers shed load, reducing effective cache service capacity. Compare hit latency and CPU by object class before and after rollout, not only fill-time benchmarks.
A corrupt entry creates a refill storm
A decoder or checksum failure causes every concurrent request to bypass the object and hit the origin. Quarantine once, collapse concurrent refills, cap retries, retain the prior readable format during rollout, and expose the corruption as a distinct cache outcome.
The test corpus becomes a fleet promise
Two highly compressible assets produce an attractive ratio, so financial and capacity plans assume the same reduction for images, tiny responses, or already compressed bundles. Segment by content type, original size, existing coding, and reuse. Treat the fresh Cloudflare number as prototype evidence, not a universal constant.
The practical conclusion
Cloudflare's two fresh cache stories are not simply arguments for better compression. The HTTP prototype gains capacity by storing one eligible representation in a private zstd form. The DNS redesign gains capacity by removing mutable-container overhead, inferring repeated data from the key, and retaining compact protocol bytes. Both work because the implementation keeps enough identity and metadata to reconstruct the public answer.
That boundary is the transferable design.
Name the selected representation before choosing its storage form. Keep public metadata separate from private codec metadata. Make validators follow observable representation changes rather than disk rewrites. Treat ranges as offsets into public representation data. Distinguish storage savings, internal-transfer savings, transmitted bytes, cache retention, and origin offload. Test cold fills, warm hits, tiers, rolling upgrades, conditional requests, HEAD, ranges, purge, and corruption.
Then a cache can evolve underneath a stable web contract. The bytes on disk may become smaller, denser, chunked, or versioned without asking clients, downstream caches, analytics, and origins to pretend they received a different resource.