feat: publish "50% Less Bandwidth Per Deploy" (Cloudpack series #4)
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
---
|
||||
title: "50% Less Bandwidth Per Deploy"
|
||||
date: 2026-05-28
|
||||
type: post
|
||||
tags: [bundler, cdn, bandwidth, cloudpack, delivery]
|
||||
summary: "Every deploy, your users re-download the whole bundle because 1% of modules changed. Delta manifests cut that in half. The browser says what it has. The server returns only what's missing."
|
||||
---
|
||||
|
||||
You changed one line in a utility function. The bundler produced new chunks. The CDN invalidated the old URLs. Every user who visits your app downloads the entire bundle from scratch.
|
||||
|
||||
One line. 100% re-download.
|
||||
|
||||
This is not a bug in your bundler. It's how content-hashing works. Change one module, the chunk hash changes, the URL changes, and the browser has no way to know that 99% of the code inside that chunk is identical to what it already had. So it fetches everything. Every byte. Every user.
|
||||
|
||||
At 50,000 modules with 10,000 daily active users, that's 95 GB of CDN bandwidth per deploy. Most of it wasted on bytes the browser already has cached under a different filename.
|
||||
|
||||
## Invert the model
|
||||
|
||||
The fix is simple: stop pretending the CDN is the source of truth about what the client needs. The client already knows what it has. Let it say so.
|
||||
|
||||
Cloudpack's [Adaptive Bundle Service](https://github.com/kenotron-ms/cloudpack) (ABS) inverts the delivery model. Instead of the browser requesting URLs and getting whatever the CDN serves, the browser sends a manifest request: "here's my entry point, here are the content hashes of every module I already have." The server computes the difference and responds with only the chunk URLs the browser still needs.
|
||||
|
||||
<div class="breakout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 390" role="img" aria-label="Diagram: Traditional CDN delivery where every deploy re-downloads everything compared to ABS delta delivery where only changed chunks are fetched">
|
||||
<rect width="840" height="390" fill="#F5F0E8" rx="4"/>
|
||||
<text x="210" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">Traditional CDN</text>
|
||||
<text x="210" y="50" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">every deploy</text>
|
||||
<text x="630" y="32" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">ABS Delta Delivery</text>
|
||||
<text x="630" y="50" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#A0522D">same deploy</text>
|
||||
<line x1="420" y1="16" x2="420" y2="370" stroke="#2B2421" stroke-width="1" stroke-dasharray="4,4"/>
|
||||
<rect x="110" y="70" width="200" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="210" y="93" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">Deploy: 1% of modules changed</text>
|
||||
<line x1="210" y1="106" x2="210" y2="124" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dd1)"/>
|
||||
<rect x="60" y="126" width="300" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="210" y="149" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">Chunk hashes change, URLs change</text>
|
||||
<line x1="210" y1="162" x2="210" y2="180" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dd1)"/>
|
||||
<rect x="60" y="182" width="300" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="210" y="205" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">Browser cache miss on new URLs</text>
|
||||
<line x1="210" y1="218" x2="210" y2="236" stroke="#A0522D" stroke-width="1.5" marker-end="url(#dd2)"/>
|
||||
<rect x="80" y="238" width="260" height="40" rx="4" fill="#A0522D" stroke="none"/>
|
||||
<text x="210" y="263" text-anchor="middle" font-family="Georgia, serif" font-size="13" font-weight="bold" fill="#F5F0E8">Re-download 100% of chunks</text>
|
||||
<text x="210" y="308" text-anchor="middle" font-family="Georgia, serif" font-size="14" font-weight="bold" fill="#A0522D">95 GB / week at scale</text>
|
||||
<text x="210" y="330" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#2B2421">All bytes. Every user. Every deploy.</text>
|
||||
<rect x="530" y="70" width="200" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="630" y="93" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">Browser: "here's what I have"</text>
|
||||
<line x1="630" y1="106" x2="630" y2="124" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dd1)"/>
|
||||
<rect x="490" y="126" width="280" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="630" y="149" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">ABS computes delta from cached_hashes</text>
|
||||
<line x1="630" y1="162" x2="630" y2="180" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dd1)"/>
|
||||
<rect x="490" y="182" width="280" height="36" rx="3" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="630" y="205" text-anchor="middle" font-family="monospace" font-size="11" fill="#2B2421">Response: fetch only changed chunks</text>
|
||||
<line x1="630" y1="218" x2="630" y2="236" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dd1)"/>
|
||||
<rect x="500" y="238" width="260" height="40" rx="4" fill="#2B2421" stroke="none"/>
|
||||
<text x="630" y="263" text-anchor="middle" font-family="Georgia, serif" font-size="13" font-weight="bold" fill="#F5F0E8">Download ~50% of chunks</text>
|
||||
<text x="630" y="308" text-anchor="middle" font-family="Georgia, serif" font-size="14" font-weight="bold" fill="#2B2421">47 GB / week saved</text>
|
||||
<text x="630" y="330" text-anchor="middle" font-family="Georgia, serif" font-size="12" fill="#2B2421">Only the bytes that actually changed.</text>
|
||||
<line x1="60" y1="352" x2="780" y2="352" stroke="#2B2421" stroke-width="0.5"/>
|
||||
<text x="420" y="376" text-anchor="middle" font-family="Georgia, serif" font-size="13" fill="#2B2421">Same deploy. Same codebase. The client tells the server what it already knows.</text>
|
||||
<defs>
|
||||
<marker id="dd1" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#2B2421"/></marker>
|
||||
<marker id="dd2" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#A0522D"/></marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
The wire protocol is two structs.
|
||||
|
||||
## The request
|
||||
|
||||
```rust
|
||||
pub struct ManifestRequest {
|
||||
/// The application entry-point (e.g. `"src/index.ts"`).
|
||||
pub entry_point: String,
|
||||
|
||||
/// SHA-256 hashes of every module the client already holds in cache.
|
||||
pub cached_hashes: Vec<ContentHash>,
|
||||
|
||||
/// Opaque build identifier issued by the last manifest response.
|
||||
/// Absent on the very first request or after a hard-reload.
|
||||
pub build_id: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Three fields. The browser sends its entry point and the content hashes of every module it has cached. The `build_id` is a staleness check: if the client's build doesn't match the server's current build, ABS ignores the cache claims and returns the full chunk set. No stale data, no partial mismatches, no guessing.
|
||||
|
||||
## The response
|
||||
|
||||
```rust
|
||||
pub struct ManifestResponse {
|
||||
/// Opaque identifier for the current server build.
|
||||
pub build_id: String,
|
||||
|
||||
/// URLs of chunks the client must fetch now.
|
||||
pub fetch_urls: Vec<String>,
|
||||
|
||||
/// URLs of chunks worth prefetching for future navigations.
|
||||
pub prefetch_urls: Vec<String>,
|
||||
|
||||
/// Cache TTL for this manifest in seconds.
|
||||
pub ttl: u64,
|
||||
}
|
||||
```
|
||||
|
||||
`fetch_urls` is what the client needs right now. `prefetch_urls` is what it will probably need soon, derived from co-request patterns observed in real production traffic. The server never tells the client to re-fetch chunks it already has. That's the whole point.
|
||||
|
||||
## The delta computation
|
||||
|
||||
The algorithm is simple. Look up which chunks the entry point needs. For each chunk, check whether every module inside it appears in the client's `cached_hashes` set. All modules present? The client already has the full chunk, skip it. Any module missing? Add the chunk's CDN URL to `fetch_urls`.
|
||||
|
||||
```rust
|
||||
pub fn compute_delta(
|
||||
manifest: &ChunkManifest,
|
||||
request: &ManifestRequest,
|
||||
cdn_base_url: &str,
|
||||
) -> ManifestResponse {
|
||||
let chunk_ids = match manifest.entry_chunks.get(&request.entry_point) {
|
||||
Some(ids) => ids,
|
||||
None => return empty_response(&manifest.build_id),
|
||||
};
|
||||
|
||||
// Trust cache only if build_id matches.
|
||||
let trust_cache = matches!(
|
||||
&request.build_id, Some(bid) if bid == &manifest.build_id
|
||||
);
|
||||
|
||||
let cached_set: HashSet<&ContentHash> = if trust_cache {
|
||||
request.cached_hashes.iter().collect()
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
let mut fetch_urls: Vec<String> = Vec::new();
|
||||
for chunk_id in chunk_ids {
|
||||
if let Some(chunk) = chunks_by_id.get(chunk_id.as_str()) {
|
||||
let fully_cached = !chunk.modules.is_empty()
|
||||
&& chunk.modules.iter().all(|m| cached_set.contains(m));
|
||||
if !fully_cached {
|
||||
fetch_urls.push(chunk_url(cdn_base_url, &chunk.hash));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ManifestResponse { build_id: manifest.build_id.clone(), fetch_urls, .. }
|
||||
}
|
||||
```
|
||||
|
||||
The `trust_cache` gate is important. If the client sends a `build_id` that doesn't match the server's current build, the `cached_set` is empty and every chunk goes into `fetch_urls`. First visit and hard-reload both degrade to a full download. No ambiguity. No stale-cache bugs.
|
||||
|
||||
<div class="breakout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 840 310" role="img" aria-label="Diagram: Delta computation flow from client request through server diff to selective chunk fetch">
|
||||
<rect width="840" height="310" fill="#F5F0E8" rx="4"/>
|
||||
<text x="420" y="30" text-anchor="middle" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#2B2421">The Delta Computation</text>
|
||||
<rect x="30" y="60" width="190" height="100" rx="4" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="125" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#2B2421" letter-spacing="0.03em">CLIENT REQUEST</text>
|
||||
<text x="125" y="108" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">entry: "src/index.ts"</text>
|
||||
<text x="125" y="124" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">cached: [a1, a2, a3, b1]</text>
|
||||
<text x="125" y="140" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">build_id: "build-42"</text>
|
||||
<line x1="220" y1="110" x2="298" y2="110" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dc1)"/>
|
||||
<text x="259" y="102" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#A0522D">POST</text>
|
||||
<rect x="305" y="50" width="230" height="120" rx="4" fill="#2B2421" stroke="none"/>
|
||||
<text x="420" y="76" text-anchor="middle" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#F5F0E8" letter-spacing="0.03em">ABS SERVER</text>
|
||||
<text x="420" y="100" text-anchor="middle" font-family="monospace" font-size="10" fill="#A0522D">build_id matches? trust cache</text>
|
||||
<text x="420" y="118" text-anchor="middle" font-family="monospace" font-size="10" fill="#A0522D">for each chunk in entry_chunks:</text>
|
||||
<text x="420" y="136" text-anchor="middle" font-family="monospace" font-size="10" fill="#A0522D"> all modules in cached_set?</text>
|
||||
<text x="420" y="154" text-anchor="middle" font-family="monospace" font-size="10" fill="#A0522D"> yes: skip. no: add to fetch_urls</text>
|
||||
<line x1="535" y1="110" x2="613" y2="110" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dc1)"/>
|
||||
<text x="574" y="102" text-anchor="middle" font-family="Georgia, serif" font-size="10" fill="#A0522D">JSON</text>
|
||||
<rect x="620" y="60" width="190" height="100" rx="4" fill="none" stroke="#2B2421" stroke-width="1.5"/>
|
||||
<text x="715" y="84" text-anchor="middle" font-family="Inter, sans-serif" font-size="12" font-weight="600" fill="#2B2421" letter-spacing="0.03em">RESPONSE</text>
|
||||
<text x="715" y="108" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">build_id: "build-43"</text>
|
||||
<text x="715" y="124" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">fetch: [chunk-c.js]</text>
|
||||
<text x="715" y="140" text-anchor="middle" font-family="monospace" font-size="10" fill="#2B2421">prefetch: [chunk-d.js]</text>
|
||||
<line x1="715" y1="160" x2="715" y2="195" stroke="#2B2421" stroke-width="1.5" marker-end="url(#dc1)"/>
|
||||
<rect x="620" y="200" width="190" height="52" rx="4" fill="none" stroke="#A0522D" stroke-width="1.5"/>
|
||||
<text x="715" y="222" text-anchor="middle" font-family="monospace" font-size="10" fill="#A0522D">GET chunk-c.js from CDN</text>
|
||||
<text x="715" y="238" text-anchor="middle" font-family="Georgia, serif" font-size="11" fill="#6B5F55">1 chunk, not 10</text>
|
||||
<line x1="60" y1="272" x2="780" y2="272" stroke="#2B2421" stroke-width="0.5"/>
|
||||
<text x="420" y="296" text-anchor="middle" font-family="Georgia, serif" font-size="13" fill="#2B2421">The browser tells ABS what it has. ABS returns only what's missing. The CDN serves chunks.</text>
|
||||
<defs>
|
||||
<marker id="dc1" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><polygon points="0 0, 8 3, 0 6" fill="#2B2421"/></marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
## Never on the critical path
|
||||
|
||||
ABS is a manifest server. It decides what to load. The CDN serves the actual code. This separation is load-bearing.
|
||||
|
||||
The service worker that mediates the protocol has a hard 100ms timeout. ABS doesn't respond in time? The service worker falls back to `manifest.json` on the CDN and the app loads exactly as it would without ABS. Full chunk set, no optimization. Works every time.
|
||||
|
||||
ABS failure degrades transparently. No blank screens. No loading spinners. No error toasts. The user gets a few extra megabytes they didn't strictly need, and the app loads fine. The optimization is invisible when it works and invisible when it doesn't.
|
||||
|
||||
## The numbers
|
||||
|
||||
Benchmarks run against synthetic codebases generated from the statistical fingerprint of real production code. [Reproducible](https://github.com/kenotron-ms/cloudpack). The table shows what a returning user downloads per deploy:
|
||||
|
||||
| Code churn | Traditional CDN | ABS delta | Savings |
|
||||
|-----------:|----------------:|----------:|--------:|
|
||||
| 0.5% | 100% | 50% | **50%** |
|
||||
| 1% | 100% | 50% | **50%** |
|
||||
| 5% | 100% | 50% | **50%** |
|
||||
| 10% | 100% | 50% | **50%** |
|
||||
| 25% | 100% | 50% | **50%** |
|
||||
|
||||
Up to 25% code churn, ABS cuts download size in half. The savings hold because the delta operates at chunk granularity: a chunk whose modules all match the client's cache is skipped entirely, regardless of how many other chunks changed.
|
||||
|
||||
At production scale (50,000 modules, 10,000 daily active users, 1% weekly churn): **47 GB saved per week.** That is not a rounding error. At $0.09/GB, it's $4.29/week in CDN costs alone, and the bandwidth savings compound with user count.
|
||||
|
||||
## The dynamic linking analogy
|
||||
|
||||
This is not a new idea. It's `ld.so` for the browser.
|
||||
|
||||
In native code, the dynamic linker loads shared libraries at runtime. It resolves symbols, checks what's already mapped, and fetches only what's missing. GOT/PLT lets the program reference code that hasn't been loaded yet. The loader fills in the addresses when they're needed.
|
||||
|
||||
ABS does the same thing. The service worker is `ld.so`. Content-hashed CDN chunks are shared libraries. The delta manifest is the symbol lookup. The browser fetches only what it doesn't already have.
|
||||
|
||||
The architect brief for Cloudpack puts it this way: "the module graph is the software, the bundle is a query." The delta manifest is just another materialization of that graph. Production bundles, dev server output, CI artifacts, per-client delta manifests: all queries against the same persistent module graph. Different clients, different cache states, different answers. One truth.
|
||||
|
||||
47 GB per week. Half the bandwidth. Same app.
|
||||
Reference in New Issue
Block a user