Lesson 02 / 16
Busy Front End
The load the server sends growing the work on the client: the time visible to the user growing while server response time stays flat, load growth and client code growth producing the same symptom being separated by the work-per-record measurement, the 26.8-times gain from moving sorting to the server, and the price moving formatting pays against the cache key.
Contents
The boundary ratio settled which of two layers should do the work: store or app. There is one more end of the chain, and there the trade changes its rule: work in the app layer is distributed by adding nodes; on the client, there is no node to add. This lesson takes on that third layer and runs the same three steps — the symptom is a number, a measurement separates two causes, and what grows in return for the fix is counted.
The main thread, long tasks, and user-centric metrics were defined and measured in the Frontend Quality course; they are not redefined here. The question here is a system design question: by what multiplier does the load the server sends grow the work on the client, and by what measurement is that multiplier seen.
Symptom: The Server Is Calm, the User Is Waiting
The seller panel opens its list of open shipments. The time visible to the user has grown over the last two releases. The server-side response-time metric has not changed; the edge meets K01’s peak load with the same numbers. The response’s byte count was also checked, and the share taken by transfer has not changed. The symptom comes down to this: the growth all happens after the work has already moved to the client.
At this point there are two causes, and neither shows up in the server’s metrics.
Cause A — the server increased the record count it sends. The client code has not changed, but the load has grown; the same code runs over more records.
Cause B — the client added work per record. The load is the same, but the new release does two extra steps per record.
An answer given without measuring is a guess, and the guess has two directions: the sentences “the server is sending too much data” and “the front end got heavier” are both drawn from the same symptom, and one of them is wrong.
The Measurement Rig
The rig below is a model. Browser time is not measured and not invented; what is counted is the steps the client has to perform on its single thread: parsed fields, transform steps, and sort comparisons. All three actually run, the counters count real calls, and the input to sorting comes from a seeded generator.
KK2 — rows visible on one screen of the panel: 25. Rationale: the list view shows a page’s worth of rows, and the rest arrives by scrolling. Its sensitivity turns into a call count in the next lesson.
// front-end/main-thread.mjs — how the load the server sends grows the work on the front end. // MODEL: the unit of work is an abstract step, duration is not measured; what is counted is // parsed fields, transform steps, and sort comparisons. All three actually run. const EVENT = 7, VISIBLE = 25, ZONE = 12, ROUTE = 40; // K01 V4 = 7 state events; KK2 = 25 rows function generate(N) { // the full data the server holds let s = 20260801 % 2147483647; // seed is visible; sort order is deterministic const rand = () => (s = (s * 48271) % 2147483647) % 100000; const shipments = []; for (let i = 0; i < N; i += 1) { const events = []; for (let j = 0; j < EVENT; j += 1) events.push({ code: (i * 3 + j) % 5, ts: rand(), zone: (i + j) % ZONE, route: `R-${(i + j) % ROUTE}`, carrier: (i + j) % 6 }); shipments.push({ no: `TR-${i}`, seller: i % 20, weight: 1 + (i % 30), updatedAt: rand(), events }); } return shipments; } const NAME = ["accepted", "transferred", "out-for-delivery", "delivered", "pending"]; function server(N, plan) { // the load leaving the edge const g = generate(N); if (plan === "raw") return g; const top = [...g].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, VISIBLE); return top.map((x) => ({ no: x.no, updatedAt: x.updatedAt, events: [...x.events].sort((a, b) => b.ts - a.ts).slice(0, 3) .map((o) => (plan === "formatted" ? { line: `${NAME[o.code]} / ${o.route} / ${o.ts}` } : { 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 client(text, { transform, sort }) { const s = { parsing: 0, transform: 0, comparison: 0, paint: 0 }; const data = JSON.parse(text); s.parsing = countFields(data); let list = data; if (sort) { // shipment list: N log N list = [...data].sort((a, b) => { s.comparison += 1; return b.updatedAt - a.updatedAt; }); for (const x of list) [...x.events].sort((a, b) => { s.comparison += 1; return b.ts - a.ts; }); } if (transform > 0) for (const x of data) for (const o of x.events) { // format first, then slice s.transform += transform; void [NAME[o.code] ?? "", String(o.ts).padStart(6, "0"), `${o.route}`, `${o.zone ?? 0}`, `${o.carrier ?? 0}`, `${o.code ?? 0}`].slice(0, transform); } for (const x of list.slice(0, VISIBLE)) { s.paint += 3; void x.no; } s.total = s.parsing + s.transform + s.comparison + s.paint; return s; } const SCENARIO = [ ["raw load, client v1", 200, "raw", { transform: 4, sort: true }], ["raw load, client v1", 400, "raw", { transform: 4, sort: true }], ["raw load, client v2", 200, "raw", { transform: 6, sort: true }], ["sorted load, client v1", 200, "sorted", { transform: 4, sort: false }], ["formatted load", 200, "formatted", { transform: 0, sort: false }], ]; const say = (x) => x.toLocaleString("en-US"); console.log(`model: a shipment carries ${EVENT} state events (K01 V4), ${VISIBLE} rows show ` + `on screen (KK2); the unit of work is an abstract step, duration is not measured`); console.log(`\n${"scenario".padEnd(26)}${"N".padStart(6)}${"records crossed".padStart(17)}` + `${"parsing".padStart(12)}${"transform".padStart(11)}${"comparison".padStart(13)}` + `${"total work".padStart(12)}${"per record".padStart(13)}${"per visible row".padStart(18)}`); const r = {}; for (const [name, N, plan, opts] of SCENARIO) { const load = server(N, plan), text = JSON.stringify(load); const s = client(text, opts); r[`${name}|${N}`] = { ...s, crossed: load.length, bytes: Buffer.byteLength(text) }; console.log(name.padEnd(26) + String(N).padStart(6) + say(load.length).padStart(17) + say(s.parsing).padStart(12) + say(s.transform).padStart(11) + say(s.comparison).padStart(13) + say(s.total).padStart(12) + (s.total / load.length).toFixed(2).padStart(13) + (s.total / VISIBLE).toFixed(1).padStart(18)); } console.log(`\nscaling (raw load, client v1): total work multiplier as N doubles`); console.log(`${"N".padStart(6)}${"total work".padStart(12)}${"x".padStart(8)}` + `${"per record".padStart(14)}${"comparison/N".padStart(15)}`); let previous = 0; for (const N of [50, 100, 200, 400, 800]) { const s = client(JSON.stringify(server(N, "raw")), { transform: 4, sort: true }); console.log(String(N).padStart(6) + say(s.total).padStart(12) + (previous ? (s.total / previous).toFixed(3) : "-").padStart(8) + (s.total / N).toFixed(2).padStart(14) + (s.comparison / N).toFixed(2).padStart(15)); previous = s.total; } const a = r["raw load, client v1|200"], b = r["raw load, client v1|400"]; const c = r["raw load, client v2|200"], d = r["sorted load, client v1|200"], e = r["formatted load|200"]; console.log(`\ndifferentiating measurement — work per record: A (server N 200 -> 400) ` + `${(a.total / a.crossed).toFixed(2)} -> ${(b.total / b.crossed).toFixed(2)}, ` + `B (client v1 -> v2, N fixed) ${(a.total / a.crossed).toFixed(2)} -> ${(c.total / c.crossed).toFixed(2)}`); console.log(`fix: total work in the sorted load ${say(a.total)} -> ${say(d.total)} ` + `(${(a.total / d.total).toFixed(1)}x), in the formatted load ${say(e.total)} ` + `(${(a.total / e.total).toFixed(1)}x); bytes crossed ${say(a.bytes)} -> ${say(d.bytes)} -> ${say(e.bytes)}`); const EDGE = 513.89, HIT = 0.9; // K01 peak edge requests/s, K02 cache hit rate console.log(`\ncondition for moving the work to the server: cacheable work runs ${(EDGE * (1 - HIT)).toFixed(2)} times/s, ` + `non-cacheable work runs ${EDGE.toFixed(2)} times/s (ratio ${(1 / (1 - HIT)).toFixed(2)}); ` + `on the client both cases run once per user`);
model: a shipment carries 7 state events (K01 V4), 25 rows show on screen (KK2); the unit of work is an abstract step, duration is not measured
scenario N records crossed parsing transform comparison total work per record per visible row
raw load, client v1 200 200 7,800 5,600 3,920 17,395 86.97 695.8
raw load, client v1 400 400 15,600 11,200 8,243 35,118 87.80 1404.7
raw load, client v2 200 200 7,800 8,400 3,920 20,195 100.97 807.8
sorted load, client v1 200 25 275 300 0 650 26.00 26.0
formatted load 200 25 125 0 0 200 8.00 8.0
scaling (raw load, client v1): total work multiplier as N doubles
N total work x per record comparison/N
50 4,321 - 86.42 17.92
100 8,649 2.002 86.49 18.74
200 17,395 2.011 86.97 19.60
400 35,118 2.019 87.80 20.61
800 70,963 2.021 88.70 21.61
differentiating measurement — work per record: A (server N 200 -> 400) 86.97 -> 87.80, B (client v1 -> v2, N fixed) 86.97 -> 100.97
fix: total work in the sorted load 17,395 -> 650 (26.8x), in the formatted load 200 (87.0x); bytes crossed 94,428 -> 3,871 -> 3,896
condition for moving the work to the server: cacheable work runs 51.39 times/s, non-cacheable work runs 513.89 times/s (ratio 10.00); on the client both cases run once per user
Work Per Record Separates the Cause
The differentiating measurement is work per record: total work divided by the number of records the server sends. The two causes separate on this single number.
In Cause A, the server goes from 200 records to 400. Total work goes from 17,395 to 35,118 — the user feels the symptom. But work per record goes from 86.97 to 87.80; a 0.95 percent change. The client code has not changed, and the measurement says exactly that.
In Cause B, the records sent stay at 200, and total work goes from 17,395 to 20,195. Work per record goes from 86.97 to 100.97; a 16.1 percent change. The load has not changed, the code has.
Same symptom, a total growing in the same direction, a ratio pointing the opposite way. Looking at the total does not diagnose; dividing does.
The scaling table says a third thing. As N doubles, total work grows by a multiplier that climbs from 2.002 to 2.021, and work per record rises from 86.42 to 88.70. That does not mean the code changed; the comparison column gives the reason: comparisons per record rise from 17.92 to 21.61. The client sorts the list itself, and sorting does not grow linearly with the record count. So load growth also grows work per record a little — and if it is known that this share is logarithmic while the code-change share is a step, the two causes can still be told apart.
Two Fixes, One Difference
The real magnitude of the symptom is in the last column. In the raw load, main-thread work per visible row is 695.8 units; the screen shows 25 rows, and the client parses and formats all 200 records, then slices them down. This is the antipattern itself: the work scales with what was sent, not with what is shown.
When the server takes over sorting and selection, total work drops from 17,395 to 650 — 26.8 times. Work per visible row falls from 695.8 to 26.0. If the server also takes over formatting, work drops to 200 — 87.0 times against the raw load, 3.25 times against the sorted load.
The two fixes are not the same thing, and the last line gives the difference. The move to the sorted load cuts bytes crossed from 94,428 to 3,871. The move to the formatted load raises bytes from 3,871 to 3,896 — the response does not shrink once formatting is done on the server. So the second step gains only work units; it carries a third cost, and that cost is the subject of the next section.
The Condition Where Leaving the Work on the Client Is Correct
Doing work on the client is not a mistake; moving it to the server has a price too, and that price can be written as a number. On the client, a piece of work runs once per user, and it runs on the user’s own hardware. Once moved to the server, the same work runs 513.89 times a second at K01’s peak load — inside a single resource pool.
What determines the condition is whether the work is cacheable. K02’s edge cache covers nine-tenths of reads; work moved to the server runs 51.39 times a second if it is cacheable, 513.89 times if it is not. The ratio is 10.00. Sorting and selection are not user-specific; the same seller’s same list is the same for everyone, it is cacheable, and it should be moved. Formatting depends on the user’s locale; if it is moved, the response becomes user-specific, and the cache key splits into as many pieces as that dimension has values. To hold the same hit rate, the cache has to be enlarged by that same multiple.
This is why the measurement separates the two fixes: the 26.8-times gain is taken; the 3.25-times gain is not. What grows in return for the second is measurable and large; what the first costs in return is only a sort that runs 51.39 times a second on the server.
What Grows in Return
Moving sorting and selection to the server has three payoffs to count. The server now sorts 51.39 times a second — behind the cache, stacking on top of K01’s load of 138.89 requests a second reaching the store. Second, the decision of which 25 records to show has moved to the server; as the user moves through the list, that decision spawns a new request each time. Third, the client no longer holds the full list: a local filter or a local re-sort now has to go to the server as well.
The last two payoffs point in the same direction, and they are the subject of the next lesson.
Summary
- The symptom does not show up in server metrics: response time and bytes hold steady while the time visible to the user grows. Both load growth and client code growth produce the identical symptom.
- The differentiating measurement is work per record: it goes 86.97 → 87.80 (0.95 percent) as the load rises from 200 to 400, and 86.97 → 100.97 (16.1 percent) as the code moves from v1 to v2.
- Load growth also grows work per record a little, but logarithmically: comparisons per record rise from 17.92 to 21.61, because sorting is done on the client.
- The antipattern’s measure is work per visible row: 695.8 units in the raw load, though the screen shows only 25 rows. Moving sorting and selection to the server drops total work by 26.8 times.
- Moving formatting as well gains another 3.25 times, but it raises bytes from 3,871 to 3,896 and makes the response user-specific; the cache key splits.
- The condition is cacheability: work moved to the server runs 51.39 times a second if it is cacheable, 513.89 times if it is not (a ratio of 10.00); on the client, both cases run once per user.
Next Step
The server now sends 25 records instead of 200, and it decides for itself which 25 to show. As the user moves through the list, that decision has to be renewed: every scroll is a request, every filter is a request, and opening a row for detail is one request more. The load shrank, the call count grew, and that count has never been measured. The next lesson takes on that count: the trade-off between call count and bytes moved, the record count at which fixed per-call cost starts to dominate bytes moved, and when a single large call is worse than many small ones.
To keep your progress and take notes, Log in
My notes
Log in to take notes.