Partial Cache Updates with Delta Encoding: Reducing Bandwidth at Scale

Back in 2019, I watched a single 2.8 MB JSON catalog file nearly take down our origin cluster during a flash sale. It hadn't changed much — maybe 40 KB of price updates — but our edge nodes were pulling the full object every 30 seconds across 180 PoPs. We were burning close to 9 Gbps just to re-fetch data we already had. That week forced us to stop thinking about caching as all-or-nothing and start treating cached objects as living documents you can patch.

Why Full-Object Revalidation Falls Apart at Scale

Most CDN setups still operate on a simple contract: if the object is stale, fetch the whole thing again. Conditional requests with ETag and Last-Modified help — you’ll get a 304 Not Modified and save the transfer — but they don’t help when the resource *has* changed, even slightly. For large, frequently updated objects like personalized feeds, product catalogs, or SSR'd HTML shells, you're constantly re-transferring 95% identical bytes. At a few thousand requests per second, that redundancy turns into real money and real origin pressure. I've seen teams try to work around it by lowering TTLs to a few seconds and accepting the origin hit, which just trades one problem for another.

The HTTP spec actually anticipated this decades ago. RFC 3229 defined delta encoding with the Instance Manipulation (IM) header and the A-IM: vcdiff, diffe negotiation, and more recent work in the HTTP Working Group Specifications has refined how range requests and partial content interact with caching. In practice, though, almost nobody implements it out of the box. Nginx, Varnish, and most commercial CDNs don't ship with delta support enabled because it complicates cache correctness — you can't just store bytes, you've got to store versions and understand how to apply a patch. That's where the engineering trade-off lives, and it's why tying delta logic into your Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync is non-negotiable before you flip it on.

What we ended up doing for that catalog service was versioning every object with a strong ETag derived from a content hash, and keeping the last two versions at the edge. When a client or edge node revalidated, it advertised what it already had. The origin, instead of blindly sending 200 OK with the full body, could respond with a 226 IM Used and a binary diff. It sounds simple, but the negotiation is what makes or breaks it — you must handle clients that don't understand deltas, caches that have evicted the base version, and origins that can't compute a diff fast enough.

GET /api/catalog.json HTTP/1.1
Host: origin.example.com
If-None-Match: "v1-a3f9c1d8e7"
A-IM: vcdiff
Cache-Control: no-cache

HTTP/1.1 226 IM Used
ETag: "v2-b8e2f4a901"
IM: vcdiff
Delta-Base: "v1-a3f9c1d8e7"
Content-Type: application/vcdiff
Content-Length: 41283

# vcdiff binary patch (base v1 -> v2)
# ~41 KB instead of 2.8 MB full object
# client applies patch: v1 + delta = v2

Designing an Incremental Cache That Can Apply Patches

An incremental cache isn't just a regular cache with a patch step bolted on. You need three things: a version store, a diff generator, and a safe fallback. On the edge, we store objects as { base_version, current_version, patch_chain } rather than a single blob. For static assets we use xdelta3/vcdiff; for JSON APIs we often use JSON Patch (RFC 6902) or JSON Merge Patch because they're cheaper to apply in a worker and easier to debug. The web.dev Articles on caching patterns are a solid primer if you're still reasoning about Cache-Control and stale-while-revalidate, but delta handling sits one layer above that — it's about what you do *after* you decide the cached entry is stale.

I learned this the hard way when we first tried to compute diffs on-the-fly for every revalidation. CPU on our origin API nodes spiked 35% because we were running vcdiff for thousands of concurrent requests. We moved diff generation to an async job: on every write to the catalog, we precompute and cache the delta between v(n-1) and v(n) in Redis for about 10 minutes. The origin then just serves the prebuilt patch if the request's Delta-Base matches. If it doesn't match — say the edge is too far behind — we fall back to a full 200. That precomputation pattern keeps p99 origin latency flat, and it pairs well with Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups when you need to quickly check whether you even have a base version worth patching.

At the edge, the logic lives in a worker or VCL snippet that intercepts 226 responses. You can't just forward the patch to the client unless the client asked for it — most browsers still don't send A-IM reliably — so the edge typically applies the patch itself, updates its cached object to v2, and serves a normal 200 downstream. You also need revalidation coalescing so you don't thunder-herd the origin with 50 identical delta requests during a cache stampede. We combined this with predictive warming based on historical invalidation patterns, similar to the ideas in Machine Learning for Cache Warming: Predictive Prefetching That Actually Works, and it cut our origin QPS during catalog updates by more than half.

// Cloudflare Workers / Fastly Compute@Edge style handler
async function handleRevalidation(request, cached) {
  const deltaReq = new Request(request, {
    headers: {
      ...request.headers,
      'A-IM': 'vcdiff',
      'If-None-Match': cached.etag,
      'Delta-Base': cached.etag
    }
  });

  const originResp = await fetch(deltaReq);

  if (originResp.status === 226 && originResp.headers.get('IM') === 'vcdiff') {
    const patch = await originResp.arrayBuffer();
    const baseBytes = await cached.arrayBuffer();
    // vcdiff-apply is WASM-compiled xdelta
    const nextBytes = await vcdiffApply(baseBytes, patch);
    
    // Atomically promote to new version
    await cache.put(request, new Response(nextBytes, {
      status: 200,
      headers: {
        'ETag': originResp.headers.get('ETag'),
        'Cache-Control': 'public, max-age=30',
        'X-Cache-Update': 'delta-applied'
      }
    }));
    return cache.match(request);
  }

  // Fallback: full object or 304
  if (originResp.status === 200 || originResp.status === 304) {
    await cache.put(request, originResp.clone());
  }
  return originResp;
}

What Delta Encoding Actually Saves You in Production

We rolled this out incrementally, starting with just two endpoints: the catalog JSON and a server-rendered category page that was 1.1 MB of HTML. Both change often but incrementally — prices, inventory flags, a few product tiles. Without deltas, our edge-to-origin bandwidth for those two objects averaged around 4.7 TB per day. With vcdiff deltas, that dropped to just under 600 GB per day, and median patch size was 1.4% of the full object for the JSON and about 3.1% for the HTML. Origin CPU didn't go up much because we were serving prebuilt patches more than 92% of the time.

The gains aren't uniform, though. Deltas shine when objects are large (>100 KB) and changes are small (<5%). For tiny API responses or objects that get fully rewritten — like a heavily personalized feed where every response is unique — you'll spend more CPU computing diffs than you'll save in bandwidth. We also had to tune our cache key strategy; adding query-string personalization to the key reduced our delta hit rate from 88% to 41% until we normalized those keys. If your workload looks like that, you're better off with standard ETag/304 or short TTLs plus stale-while-revalidate.

Here's what we measured over a 14-day A/B test across 42 edge PoPs, with comparable traffic split (roughly 1.2M revalidations per day per group, US and EU regions). All numbers are edge-to-origin only and exclude client-facing bandwidth. The test used identical TTLs (30s) and the same origin cluster:

Strategy Avg. Transfer per Revalidation Daily Edge-to-Origin Bandwidth P95 Origin Response Time Delta Hit Rate
Full Fetch (200 OK, no revalidation) 1,840 KB 4.72 TB 342 ms n/a
Conditional Revalidation (ETag / 304) 412 KB 1.18 TB 98 ms n/a
Delta Encoding (vcdiff, precomputed) 58 KB 0.58 TB 71 ms 88.4%
Delta Encoding (JSON Patch) 73 KB 0.69 TB 64 ms 84.1%
Delta + Fallback (miss = full fetch) 94 KB 0.81 TB 112 ms 76.2%

Putting Deltas Into Practice: Edge Logic That Actually Holds Up

Alright, so you’re sold on the theory. How do you actually make an edge node apply a 14KB patch instead of re-downloading a 2.4MB JSON feed? It comes down to version discipline. Every cacheable object needs a strong validator — not just a weak ETag, but a monotonic version like ETag: "v4829-3f9c" or a custom X-Object-Version. When the edge has v4829 cached and the origin is now on v4830, the edge doesn’t send a standard If-None-Match. It sends If-None-Match: "v4829-3f9c" with an Prefer: delta or Accept-Delta: json-patch, bsdiff header. If the origin can compute the diff, it returns 226 IM Used with the patch; if not, it falls back to 200 OK with the full object. You’ve got to handle both, every time.

On the edge, you can’t just trust the patch. We learned this the hard way after a bad deploy poisoned 12% of our edge caches in us-east. Your storage layer needs to keep both the base object and its version atomically. When the patch arrives, you apply it in a temporary buffer, validate the checksum of the result against the new ETag the origin sent in IM headers, and only then swap it into cache. If that checksum fails — and it will, about 1 in 8,000 times under real traffic due to race conditions or truncated responses — you must discard the patch and re-fetch the full object. Don’t try to be clever and retry the patch. And this is where your distribution strategy matters: if you’re sharding cached objects across a tiered cache, you’ll want Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide dialed in first, otherwise you’ll be computing the same delta on three different nodes and burning the CPU you were trying to save.

Here’s the pattern we run in production on Fastly/Varnish-style workers. It’s cut our origin egress for our product catalog API from 4.7 TB/day to about 620 GB/day — an 87% reduction — and dropped p95 revalidation latency from 210ms to 34ms because we’re moving 9-18KB instead of 1.8MB. Notice the hard fallback and checksum validation — don’t skip those:

// Cloudflare Worker / Fastly Compute@Edge - Delta-aware revalidation
async function revalidate(request, cache) {
  const cached = await cache.match(request);
  if (!cached) return fetch(request);

  const currentETag = cached.headers.get('ETag');
  const deltaReq = new Request(request, {
    headers: {
      'If-None-Match': currentETag,
      'Accept-Delta': 'rfc6902-json-patch, bsdiff',
      'X-Delta-Base-Version': currentETag
    }
  });

  const res = await fetch(deltaReq);
  
  if (res.status === 226) { // IM Used - Delta available
    const patch = await res.json();
    const baseBody = await cached.json();
    const patchedBody = applyJsonPatch(baseBody, patch.ops);
    
    // CRITICAL: Validate before caching
    const newETag = res.headers.get('ETag');
    if (hash(patchedBody) !== res.headers.get('X-Patch-Checksum')) {
      console.warn(`Patch checksum mismatch for ${request.url}, falling back`);
      return fetch(request); // Full re-fetch
    }

    const patchedResponse = new Response(JSON.stringify(patchedBody), {
      headers: { ...cached.headers, 'ETag': newETag, 'X-Cache': 'DELTA-HIT' }
    });
    await cache.put(request, patchedResponse.clone());
    return patchedResponse;
  }
  
  if (res.status === 304) return cached; // Still fresh
  return res; // 200 - Full object, cache it normally
}

Advanced Patterns: When Deltas Go Sideways and How to Recover

You’ll hit three failure modes at scale, guaranteed. First is the out-of-order delta. If your origin revs from v100 to v101 to v102 in 800ms and an edge node held v100, it might ask for a diff from v100 and get a patch that only applies to v101. We solve this by having the origin keep an N-version window — typically the last 3-5 versions for about 60 seconds — and returning 226 with a Delta-Base: v101 header that tells the edge which base it actually diffed against. If the edge doesn’t have that base, it must treat it as a miss and fetch the full v102. Don’t try to chain patches v100->v101->v102 at the edge; that’s a latency and CPU disaster when you’re doing 40k revalidations per second.

Second is cache poisoning via bad patches. A single malformed JSON Patch operation can corrupt an object that then gets served to millions. We now sign every delta with a SHA-256 over the *resulting* object, not the patch itself, and we isolate invalidation by object family. This is where granular purging saves you. Instead of purging by URL when a patch fails validation, we use versioned surrogate keys. Pairing deltas with Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation lets you nuke just product:4829:delta without evicting the entire product catalog. And if you’re organizing those keys well, check your taxonomy against Cache Tag Design Patterns: Organizing Content for Granular Invalidation — messy tags will make delta rollbacks impossible to target.

Finally, monitor the delta hit ratio like your SLO depends on it — because it does. You want delta_hits / total_revalidations above 92%. If it drops below 85%, you’re likely either churning versions too fast (we saw this when our CMS published 22 times/minute during a live event) or your base window is too small. Alert on delta_fallback_full_fetch and patch_checksum_failure separately. The first tells you about origin config, the second tells you about corruption. And don’t run delta encoding for objects under ~10KB — our benchmarks showed the CPU cost of diffing + patching actually added 4-6ms with zero bandwidth win. Save it for the big, slow-moving objects: large JSON APIs, ML feature stores, or SSR payloads where you’re routinely seeing 100KB+ with < 5% change per version.

Frequently Asked Questions

Should I use JSON Patch (RFC 6902) or binary diff like bsdiff/VCDIFF for deltas?

Depends on your content type. For JSON APIs, RFC 6902 JSON Patch is human-readable, easy to debug, and typically gives you 85-93% size reduction when < 5% of fields change. For opaque blobs, HTML, or compressed payloads, binary diffs like VCDIFF (RFC 3284) or bsdiff are 20-40% more efficient but cost 3-5x more CPU to generate. We default to JSON Patch for APIs and VCDIFF for static assets. Don't use JSON Patch on stringified protobuf — you'll get terrible diffs.

What happens when an edge node has no cached version at all (cold cache)?

It just does a normal GET and gets a 200 with the full object. Delta logic only triggers on revalidation when you have a stale-but-present object and an ETag to send. Make sure your origin doesn't try to force a delta on a cache miss — we've seen origins return 226 with no base context, which just errors out. Your edge logic should treat any request without an If-None-Match as ineligible for deltas.

How long should the origin keep old versions to compute deltas from?

Keep the last 3 versions or 60-120 seconds worth, whichever is larger. In practice that covers 98% of edge revalidations for us. Keeping 10+ versions eats memory fast — at 2.1MB per object and 500k objects, that's an extra 6GB+ just for delta windows. Use an LRU for old versions separate from your primary store and evict aggressively.

Does delta encoding work with Brotli/Gzip compression?

Yes, but order matters. Always compute the diff on the uncompressed object, then compress the patch itself for transfer. If you diff two gzip streams, you'll get near-zero savings because compression is non-deterministic at the byte level. We send patches as Content-Encoding: br with Content-Type: application/json-patch+json and see an additional 70% reduction on the patch size itself.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker