Lesson 04 / 16
Extraneous Fetching
Measuring over-fetching end to end: a wide record lowering the number of entries that fit in cache, key variety producing the identical hit-rate drop, the measurement that splits the two into the working set's two factors, and the threshold where moving extra fields is justified being bounded by a call's envelope.
Contents
Batching the call cut what moves down to a third, but nobody looked inside what it carries. The previous lesson’s measurement worked at record granularity: it counted whether a record arrived, not whether the fields inside it were read. Waste happens at field granularity too, and that is this lesson’s subject.
The catalog title is Extraneous Fetching; the term used in the body is over-fetching, the same shape as in the Backend curriculum’s Data Access Layer and Business Logic course. The concept was built there and its bytes were measured: column selection, row limiting, the silent cost of selecting everything. That mechanic is not repeated here. The question here is a system design question: what does the unnecessary byte grow in the layers outside the store, and what hardens when it is cut.
Symptom: Hit Rate Drops, Request Count Holds
The symptom shows up in the edge cache. K02’s edge covered nine-tenths of reads; K01’s V9 assumption also projects a 0.90 hit rate, which puts reads reaching the store at 41.67 a second. The measured hit rate fell below 0.90, and reads reaching the store grew. The request count at the edge did not change.
Cause A — the record widened. The same number of entries, each carrying more bytes. The number of entries that fit in cache fell, and eviction rose.
Cause B — the key diversified. The record stays the same size, but the same shipment now produces multiple keys: responses that branch by language, format, or consumer. Entry count stayed the same; the working set grew.
Both produce the same symptom, and their fixes live in different places: one concerns the response’s width, the other its variety.
The Measurement Rig
The cache is a real least recently used (LRU) arrangement, bounded by a byte capacity. The request trace comes from a seeded Zipf generator; a handful of tracking numbers get queried a lot, most get queried rarely.
KK4 — memory the edge cache allots this record type: 1 MiB (at model scale). Rationale: the full twenty-thousand-shipment universe must not fit, or eviction never happens and the price of width never shows up. Its sensitivity is given at 2x and 4x in the last table.
// fields/end-to-end.mjs — the end-to-end cost of unnecessary bytes: entries that fit in cache, // hit rate, requests reaching the store, and network egress. The cache is a real LRU, the trace // comes from a seeded Zipf generator. const UNIVERSE = 20_000, TRACE_LEN = 200_000, CAPACITY = 1 << 20; // KK4: cache 1 MiB (model scale) const EVENT = 7; // K01 V4 const record = (i) => ({ no: `TR-2026-${String(i).padStart(9, "0")}`, seller: i % 20, day: i % 30, tariffId: i % 40, zoneId: i % 12, weight: 1 + (i % 30), volume: 10 + (i % 90), contract: `CT-${i % 20}`, rate: 12 + (i % 7) * 0.5, coefficient: 1 + (i % 5) * 0.1, discount: (i % 9) * 0.01, createdAt: 1700000000 + i * 37, updatedAt: 1700000000 + i * 41, state: i % 5, carrier: `CAR-${i % 6}`, routeCode: `R-${i % 40}-${i % 12}`, deliveryWindow: `${8 + (i % 4)}:00-${12 + (i % 4)}:00`, events: Array.from({ length: EVENT }, (_, j) => ({ code: (i * 3 + j) % 5, ts: 1700000000 + i * 41 + j * 900, zone: (i + j) % 12, route: `R-${(i + j) % 40}`, carrier: `CAR-${(i + j) % 6}`, seq: j, note: `step ${j} record received at hub ${(i + j) % 12}` })), }); const NAME = ["accepted", "transferred", "out-for-delivery", "delivered", "pending"]; const FORMAT = { full: (r) => r, medium: (r) => ({ no: r.no, state: r.state, zoneId: r.zoneId, updatedAt: r.updatedAt, carrier: r.carrier, routeCode: r.routeCode, weight: r.weight, events: r.events.slice(-3).map((o) => ({ code: o.code, ts: o.ts, route: o.route, zone: o.zone, note: o.note })) }), needed: (r) => ({ no: r.no, state: NAME[r.state], zone: r.zoneId, updatedAt: r.updatedAt, events: r.events.slice(-3).map((o) => ({ code: o.code, ts: o.ts, route: o.route })) }), }; const countFields = (x) => Array.isArray(x) ? x.reduce((a, y) => a + countFields(y), 0) : (x && typeof x === "object") ? Object.values(x).reduce((a, y) => a + countFields(y), 0) : 1; function buildTrace() { // seeded Zipf: popular tracking numbers let s = 20260801 % 2147483647; const rand = () => (s = (s * 48271) % 2147483647) / 2147483647; const w = Array.from({ length: UNIVERSE }, (_, i) => 1 / (i + 1)); const cum = w.reduce((a, x, i) => (a.push((a[i - 1] ?? 0) + x), a), []); const total = cum[UNIVERSE - 1]; const draw = () => { const h = rand() * total; let a = 0, b = UNIVERSE - 1; while (a < b) { const m = (a + b) >> 1; if (cum[m] < h) a = m + 1; else b = m; } return a; }; return Array.from({ length: TRACE_LEN }, draw); } const TRACE = buildTrace(); function run(format, variety, capacity = CAPACITY) { // variety: distinct keys the same record produces const cache = new Map(); // LRU: Map preserves insertion order let filled = 0, hits = 0, moved = 0, serializedFields = 0; for (let t = 0; t < TRACE.length; t += 1) { const i = TRACE[t], key = `${i}|${t % variety}`; const body = FORMAT[format](record(i)); const b = Buffer.byteLength(JSON.stringify(body)); moved += b; if (cache.has(key)) { hits += 1; cache.delete(key); cache.set(key, b); continue; } serializedFields += countFields(body); // serialized on a miss while (filled + b > capacity && cache.size > 0) { const [oldest, oldestBytes] = cache.entries().next().value; cache.delete(oldest); filled -= oldestBytes; } cache.set(key, b); filled += b; } const recordBytes = Buffer.byteLength(JSON.stringify(FORMAT[format](record(1)))); return { bytes: recordBytes, fields: countFields(FORMAT[format](record(1))), variety, entries: Math.floor(capacity / recordBytes), hit: hits / TRACE_LEN, serializedFields, moved }; } const READ = 416.67, K01_HIT = 0.9; // K01: peak read requests/s, V9 const SCENARIO = [["full response", "full", 1], ["medium response", "medium", 1], ["as-needed response", "needed", 1], ["as-needed, 4-variety key", "needed", 4]]; const say = (x) => x.toLocaleString("en-US"); console.log(`model: ${say(UNIVERSE)} shipments, ${say(TRACE_LEN)} requests (seeded Zipf, seed 20260801), ` + `cache ${CAPACITY / 1024} KiB; K01: V5 tracking response 480 bytes, V7 shipment record 900 bytes`); console.log(`\n${"scenario".padEnd(28)}${"record bytes".padStart(14)}${"fields".padStart(8)}` + `${"entries that fit".padStart(19)}${"key variety".padStart(13)}${"hit".padStart(9)}` + `${"requests to store/s".padStart(21)}${"network egress Mbit/s".padStart(23)}`); const results = {}; for (const [name, format, variety] of SCENARIO) { const o = run(format, variety); results[name] = o; console.log(name.padEnd(28) + say(o.bytes).padStart(14) + String(o.fields).padStart(8) + say(o.entries).padStart(19) + String(variety).padStart(13) + o.hit.toFixed(4).padStart(9) + (READ * (1 - o.hit)).toFixed(2).padStart(21) + ((READ * o.bytes * 8) / 1e6).toFixed(3).padStart(23)); } console.log(`K01 assumption: hit ${K01_HIT} -> requests to store ${(READ * (1 - K01_HIT)).toFixed(2)}/s`); const full = results["full response"], needed = results["as-needed response"], variety4 = results["as-needed, 4-variety key"]; console.log(`\ndifferentiating measurement — working set = key variety x record bytes`); console.log(`${"scenario".padEnd(28)}${"bytes per key".padStart(15)}${"variety".padStart(9)}` + `${"working-set bytes".padStart(20)}${"vs. needed".padStart(13)}`); for (const name of ["full response", "as-needed response", "as-needed, 4-variety key"]) console.log(name.padEnd(28) + say(results[name].bytes).padStart(15) + String(results[name].variety).padStart(9) + say(results[name].bytes * results[name].variety).padStart(20) + ((results[name].bytes * results[name].variety) / needed.bytes).toFixed(2).padStart(13)); console.log(`\nend-to-end difference (full -> needed): record ${say(full.bytes)} -> ${say(needed.bytes)} bytes ` + `(${(full.bytes / needed.bytes).toFixed(2)}x), entries that fit ${say(full.entries)} -> ${say(needed.entries)}, ` + `hit ${full.hit.toFixed(4)} -> ${needed.hit.toFixed(4)}, requests to store ` + `${(READ * (1 - full.hit)).toFixed(2)} -> ${(READ * (1 - needed.hit)).toFixed(2)}/s`); console.log(`fields serialized (on misses): ${say(full.serializedFields)} -> ${say(needed.serializedFields)} ` + `(${(full.serializedFields / needed.serializedFields).toFixed(2)}x); network egress ` + `${((READ * full.bytes * 8) / 1e6).toFixed(3)} -> ${((READ * needed.bytes * 8) / 1e6).toFixed(3)} Mbit/s`); console.log(`the cost of key variety (needed -> 4 varieties): hit ${needed.hit.toFixed(4)} -> ` + `${variety4.hit.toFixed(4)}, requests to store ${(READ * (1 - needed.hit)).toFixed(2)} -> ` + `${(READ * (1 - variety4.hit)).toFixed(2)}/s, network egress unchanged`); const ENVELOPE = 282; // lesson 03: a call's envelope console.log(`\nthe threshold where moving extra fields is justified: if it eliminates a call, the excess ` + `must be smaller than ${ENVELOPE} bytes (lesson 03's envelope); the measured excess is ` + `medium ${say(results["medium response"].bytes - needed.bytes)} bytes, full ${say(full.bytes - needed.bytes)} bytes`); console.log(`\nKK4 sensitivity (cache ${CAPACITY / 1024} KiB -> 2x and 4x) hit`); console.log(`${"scenario".padEnd(28)}${"1x".padStart(9)}${"2x".padStart(9)}${"4x".padStart(9)}`); for (const [name, format, variety] of SCENARIO) console.log(name.padEnd(28) + [1, 2, 4] .map((k) => run(format, variety, CAPACITY * k).hit.toFixed(4).padStart(9)).join(""));
model: 20,000 shipments, 200,000 requests (seeded Zipf, seed 20260801), cache 1024 KiB; K01: V5 tracking response 480 bytes, V7 shipment record 900 bytes scenario record bytes fields entries that fit key variety hit requests to store/s network egress Mbit/s full response 1,110 66 944 1 0.6042 164.92 3.700 medium response 403 22 2,601 1 0.7246 114.77 1.343 as-needed response 214 13 4,899 1 0.8007 83.03 0.713 as-needed, 4-variety key 214 13 4,899 4 0.6315 153.55 0.713 K01 assumption: hit 0.9 -> requests to store 41.67/s differentiating measurement — working set = key variety x record bytes scenario bytes per key variety working-set bytes vs. needed full response 1,110 1 1,110 5.19 as-needed response 214 1 214 1.00 as-needed, 4-variety key 214 4 856 4.00 end-to-end difference (full -> needed): record 1,110 -> 214 bytes (5.19x), entries that fit 944 -> 4,899, hit 0.6042 -> 0.8007, requests to store 164.92 -> 83.03/s fields serialized (on misses): 5,224,494 -> 518,115 (10.08x); network egress 3.700 -> 0.713 Mbit/s the cost of key variety (needed -> 4 varieties): hit 0.8007 -> 0.6315, requests to store 83.03 -> 153.55/s, network egress unchanged the threshold where moving extra fields is justified: if it eliminates a call, the excess must be smaller than 282 bytes (lesson 03's envelope); the measured excess is medium 189 bytes, full 896 bytes KK4 sensitivity (cache 1024 KiB -> 2x and 4x) hit scenario 1x 2x 4x full response 0.6042 0.6859 0.7701 medium response 0.7246 0.8073 0.8847 as-needed response 0.8007 0.8793 0.9157 as-needed, 4-variety key 0.6315 0.7083 0.7763
The numbers belong to the measurement class and depend on the seed; the columns converted to K01 scale, requests/s and Mbit/s, belong to the calculation class. The model’s as-needed response comes out to 214 bytes, while K01’s V5 assumption was 480 bytes — the assumption was more conservative, and what travels across scales is the ratio between scenarios, not the absolute byte count.
Record Widening and Key Variety Produce the Same Symptom
The first and fourth rows of the first table are this whole lesson’s point. In the full response, the hit rate is 0.6042 and reads reaching the store are 164.92 a second. In the as-needed response that still produces four key varieties, the hit rate is 0.6315 and reads reaching the store are 153.55. Both scenarios produce the same symptom — a hit rate far below 0.90, reads reaching the store at roughly four times K01’s projected 41.67 — yet one comes from a wide record, the other from key variety.
The differentiating measurement is in the second table, and it is a single product: working set = key variety × bytes per key. Taking the as-needed response as the baseline, the full response’s working set is 5.19 times larger, the four-variety response’s is 4.00 times larger. Which of the two factors grew tells the cause: in the full response it is bytes (1,110 against 214), in the four-variety response it is variety (4 against 1).
A third column confirms the diagnosis. Network egress is 3.700 Mbit/s for the full response, 0.713 Mbit/s for the four-variety response — unchanged from the as-needed baseline. Same hit-rate drop, very different network egress. A dashboard that looks only at hit rate cannot separate the two causes; the working set’s two factors do.
The Unnecessary Byte’s Cost in Four Separate Places
The move from the full response to the as-needed response shifts four quantities at once, and not by the same ratio.
The record drops from 1,110 bytes to 214: 5.19 times. Entries that fit in cache rise from 944 to 4,899; the ratio is again 5.19, because capacity is denominated in bytes. The hit rate rises from 0.6042 to 0.8007 — this ratio is not 5.19, because hit rate is not linearly tied to entry count, it is tied to the popularity distribution. Reads reaching the store drop from 164.92 to 83.03 a second: 1.99 times.
The count of fields serialized drops from 5,224,494 to 518,115: 10.08 times. That is nearly double the record ratio (5.19), and the reason is compound: fields per record drop from 66 to 13, and misses themselves also thin out. The serialization job shrinks by two factors at once.
Network egress drops from 3.700 Mbit/s to 0.713, exactly 5.19 times. K01’s peak read egress was 1.6 Mbit/s; the full response pushes it to 2.31 times that.
The KK4 sensitivity table carries a warning: quadrupling the cache brings the full response’s hit rate from 0.6042 to 0.7701, and it still does not catch up to the as-needed response’s 0.8007 at 1x. Adding memory does not close the width gap.
The Threshold Where Moving Extra Fields Is Justified
Moving extra fields is not always waste; if it eliminates a call, it has a payoff, and the threshold for that payoff comes from the previous lesson. A call’s envelope was 282 bytes. So the rule is this: the excess moved is justified if it is smaller than the envelope of the call it eliminates.
The measurement applies the threshold to both sides. The medium response’s excess is 189 bytes; below 282, so moving these fields pays off if they make a second call unnecessary. The full response’s excess is 896 bytes; larger than three envelopes, so it cannot be defended unless it eliminates three separate calls.
The threshold depends on scale, and the direction flips when scale changes. If the envelope shrinks under a compressed protocol, the threshold drops, and the medium response also becomes indefensible; if a record’s popularity is very high, it is already sitting in cache, and the excess’s hit-rate cost approaches zero. The antipattern is not a prohibition; it is a comparison between these two numbers.
What Grows in Return
The payoff for narrowing the fields sits in the fourth row, and so does the trap. Trimming a response to one consumer’s exact need does not serve a second consumer; the second consumer wants its own field set, and a second variety appears. Four consumers means four varieties, and the measurement gives the price: hit rate from 0.8007 to 0.6315, reads reaching the store from 83.03 to 153.55 a second.
Cause A’s fix gives rise to Cause B. Narrowing cannot be pushed indefinitely; past a point, a single medium response comes cheaper than four narrow ones. The measurement says which is cheaper: the medium response, with one variety, gives a 0.7246 hit rate and 114.77 requests a second, while the narrow response, with four varieties, gives 0.6315 and 153.55. At this model’s scale, the medium response wins.
The second payoff is in the contract, and it is not counted: a narrow response writes today’s consumer need into the response’s shape. Every time a new field is requested, either the response widens or a new variety is born; both have a price, and it is in the table above.
Summary
- The symptom is in the edge cache: while request count holds steady, the hit rate falls below K01’s V9 assumption of 0.90, and reads reaching the store climb to multiples of 41.67 a second.
- Both record widening (hit rate 0.6042) and key variety (0.6315) produce the same symptom; the differentiating measurement is the working set’s two factors — bytes at 5.19 times in the full response, variety at 4.00 times in the four-variety response. Network egress confirms it: 3.700 against 0.713 Mbit/s.
- The unnecessary byte grows four places at once, and not by the same ratio: record 5.19 times, entries that fit 5.19 times, reads reaching the store 1.99 times, fields serialized 10.08 times.
- Adding memory does not close the width gap: quadrupling the cache brings the full response’s hit rate to 0.7701, still short of the as-needed response’s 0.8007 at 1x.
- The excess is justified if it is smaller than the envelope of the call it eliminates (282 bytes): the medium response’s excess is 189 bytes, the full response’s is 896 bytes.
- The payoff of narrowing is variety: giving every consumer its own narrow response drops the hit rate from 0.8007 to 0.6315, and at this model’s scale, a single medium response (0.7246) comes cheaper.
Next Step
The four measurements so far all stood in the same place: the data a request carries. The layer doing the computation, the client’s work, the call’s granularity, the field’s width — all of it concerned what was moved. But part of a request’s cost never moves at all. A request cannot start before the objects that serve it are set up: a store connection, a serializer, a signature validator, a format parser. When these objects get set up has never been asked so far; all of them were assumed to already stand ready. The next lesson measures that assumption: an expensive client being re-created on every request, the per-object setup cost multiplied by how many times it repeats, and what a pool grows in return.
To keep your progress and take notes, Log in
My notes
Log in to take notes.