---
title: 'Busy Front End'
source: 'https://academia.sh/en/courses/performance-and-monitoring/busy-front-end'
course: 'Performance Anti-Patterns and Monitoring'
language: en
updated: '2026-08-23T07:01:27+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
