Skip to content
academia.sh

Lesson 14 / 16

Backends for Frontends

Splitting the edge by client type: separating three clients' field sets, measuring the unused fields and request count a single merged body carries, the batch-endpoint requirement separate edge units impose, and the client mix's effect on K01's egress calculation.

Contents

The previous three lessons treated the edge as a single surface: every client arrives at the same address, passes the same checks, and receives the same merged body when aggregation runs. But the clients asking for tracking information do not want the same thing. A mobile tracking card shows only the state and update time; a web tracking page also opens the zone and route steps; a seller panel wants the shipment list along with the fee line items.

Backends for frontends splits the edge into a separate unit per client type: each client gets its own edge unit, and that unit returns only the fields that client wants, in the shape that client wants. This lesson compares two layouts and looks for the problem’s measure in the unused field count and the request count. Over-fetching was built in the Web API Design course and measured there over interface styles; here the same phenomenon is treated as an edge layout decision.

Three Clients, Three Field Sets

The model is in-process: there is no real client, browser, or separate process. The merged body is the body the previous lesson measured; field sets are listed per client type. The shipments field says how many records a view asks for, the share field says what portion of tracking queries comes from that client.

// edge/client.mjs — three client types, the fields they want, and which service each field lives in
export const RECORD = {
  trackingNo: "TR-4821", state: "out-for-delivery", zone: "35",
  updatedAt: "2026-03-11T08:24:00Z", route: ["34", "41", "35"],
  fee: { tariff: 9600, discount: 1440, net: 8160, contractNo: "S7" },
};

export const SERVICE_FIELDS = {
  "delivery-ops": ["trackingNo", "state", "zone", "updatedAt", "route"],
  billing: ["fee"],
};

// shipments: how many records a view asks for; share: what portion of tracking queries (T2)
export const CLIENT = {
  "mobile card": { fields: ["trackingNo", "state", "updatedAt"], shipments: 1, share: 0.6 },
  "web page": { fields: ["trackingNo", "state", "zone", "updatedAt", "route"], shipments: 1, share: 0.3 },
  "seller panel": { fields: ["trackingNo", "state", "fee"], shipments: 5, share: 0.1 },
};

export const leaf = (d) => (typeof d !== "object" || d === null ? 1
  : Object.values(d).reduce((t, v) => t + leaf(v), 0));
export const pick = (record, fields) => Object.fromEntries(fields.map((a) => [a, record[a]]));
export const bytes = (d) => Buffer.byteLength(JSON.stringify(d));
export const serviceCount = (fields) => Object.values(SERVICE_FIELDS)
  .filter((list) => fields.some((a) => list.includes(a))).length;

The share values are this course’s assumption (T2): sixty percent of tracking queries come from the mobile card, thirty percent from the web page, ten percent from the seller panel. It is not added to K01’s table, because K01 did not separate client types. The reasoning is that most tracking queries are made by a recipient for a single shipment; its sensitivity is calculated below.

// edge/measure.mjs — measures the single-edge and backend-for-frontend layouts and ties them to K01's egress
import { RECORD, CLIENT, leaf, pick, bytes, serviceCount } from "./client.mjs";

const FULL = leaf(RECORD);                    // leaf field count the merged body carries

function singleEdge(i) {                      // every client gets the same merged body
  const used = leaf(pick(RECORD, i.fields));
  return { requests: i.shipments, bytes: i.shipments * bytes(RECORD), carried: i.shipments * FULL,
    unused: i.shipments * (FULL - used), internalRequests: 2 * i.shipments };
}

function backendForFrontend(i) {              // a separate edge unit per client type
  const body = i.shipments === 1 ? pick(RECORD, i.fields)
    : Array.from({ length: i.shipments }, () => pick(RECORD, i.fields));
  return { requests: 1, bytes: bytes(body), carried: leaf(body), unused: 0,
    internalRequests: serviceCount(i.fields) };
}

const FIELD = ["requests", "bytes", "carried", "unused", "internalRequests"];
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  console.log(`\n-- ${name} --`);
  console.log(`${"client".padEnd(15)}${FIELD.map((a) => a.padStart(17)).join("")}`);
  let t = Object.fromEntries(FIELD.map((a) => [a, 0]));
  for (const [i, def] of Object.entries(CLIENT)) {
    const r = measure(def);
    FIELD.forEach((a) => { t[a] += r[a]; });
    console.log(`${i.padEnd(15)}${FIELD.map((a) => String(r[a]).padStart(17)).join("")}`);
  }
  console.log(`${"total".padEnd(15)}${FIELD.map((a) => String(t[a]).padStart(17)).join("")}`);
  console.log(`edge units = ${name === "single edge" ? 1 : Object.keys(CLIENT).length}, ` +
    `clients affected by one added field = ${name === "single edge" ? Object.keys(CLIENT).length : 1}, ` +
    `batch endpoints the service must offer = ${name === "single edge" ? 0 : 2}`);
}

// K01 Back-of-the-Envelope Estimation: V5 = 480 bytes (assumption), read peak 416.67 requests/s (computed value).
// Scale: the model's state body is 115 bytes, K01's tracking response is 480 bytes.
const V5 = 480, READ_PEAK = 416.67, STATE_BYTES = bytes(pick(RECORD, ["trackingNo", "state", "zone",
  "updatedAt", "route"]));
const SCALE = V5 / STATE_BYTES;
const mbit = (requests, bytes) => (requests * bytes * 8) / 1e6;
console.log(`\nstate body = ${STATE_BYTES} bytes, K01 scale = ${SCALE.toFixed(4)} (480 / ${STATE_BYTES})`);

console.log(`${"layout".padEnd(21)}${"requests/s".padStart(11)}${"bytes/request".padStart(15)}${"Mbit/s".padStart(9)}`);
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  let requests = 0, totalBytes = 0;
  for (const def of Object.values(CLIENT)) {
    const r = measure(def);
    requests += READ_PEAK * def.share * r.requests;
    totalBytes += READ_PEAK * def.share * r.bytes * SCALE;
  }
  console.log(`${name.padEnd(21)}${requests.toFixed(2).padStart(11)}` +
    `${(totalBytes / requests).toFixed(0).padStart(15)}${((totalBytes * 8) / 1e6).toFixed(2).padStart(9)}`);
}
console.log(`K01 (single client type)${READ_PEAK.toFixed(2).padStart(7)}${String(V5).padStart(15)}` +
  `${mbit(READ_PEAK, V5).toFixed(2).padStart(9)}`);

// T2 sensitivity: mobile share 0.30 instead of 0.60, the difference moving to the web page
const T2 = { "mobile card": 0.3, "web page": 0.6, "seller panel": 0.1 };
for (const [name, measure] of [["single edge", singleEdge], ["backend for frontend", backendForFrontend]]) {
  let requests = 0, totalBytes = 0;
  for (const [i, def] of Object.entries(CLIENT)) {
    const r = measure(def);
    requests += READ_PEAK * T2[i] * r.requests;
    totalBytes += READ_PEAK * T2[i] * r.bytes * SCALE;
  }
  console.log(`T2 mobile 0.30 -> ${name.padEnd(21)}${requests.toFixed(2).padStart(9)} requests/s` +
    `${((totalBytes * 8) / 1e6).toFixed(2).padStart(7)} Mbit/s`);
}
-- single edge --
client                  requests            bytes          carried           unused internalRequests
mobile card                    1              190               11                8                2
web page                       1              190               11                4                2
seller panel                   5              950               55               25               10
total                          7             1330               77               37               14
edge units = 1, clients affected by one added field = 3, batch endpoints the service must offer = 0

-- backend for frontend --
client                  requests            bytes          carried           unused internalRequests
mobile card                    1               86                3                0                1
web page                       1              123                7                0                1
seller panel                   1              596               30                0                2
total                          3              805               40                0                4
edge units = 3, clients affected by one added field = 1, batch endpoints the service must offer = 2

state body = 123 bytes, K01 scale = 3.9024 (480 / 123)
layout                requests/s  bytes/request   Mbit/s
single edge               583.34            741     3.46
backend for frontend      416.67            578     1.93
K01 (single client type) 416.67            480     1.60
T2 mobile 0.30 -> single edge             583.34 requests/s   3.46 Mbit/s
T2 mobile 0.30 -> backend for frontend    416.67 requests/s   2.07 Mbit/s

Unused Fields and Request Count

Under a single edge, the three clients’ 7 requests together carry 77 fields, and 37 of those go unused — forty-eight percent of the fields carried. The split is not even: the mobile card does not use 8 of 11 fields, the web page 4, the seller panel 25 across five records. The smallest client carries the most excess, because the merged body is shaped to the widest client’s need.

Under backends for frontends this number drops to zero; what is interesting is the request count. Total requests fall from 7 to 3, and the entire drop comes from the seller panel: the panel made five requests for five shipments, now it gets the list in one request. This is the edge form of the N+1 pattern measured in the Web API Design course — a layout that fetches a list and then makes a separate request per row. Backends for frontends does not eliminate it, it moves it behind the edge: the internalRequests column brings the panel from 10 down to 2, not down to zero.

Body bytes also fall, from 1330 to 805, 0.61 times. This number moves in the same direction as fields carried falling from 77 to 40, but not at the same ratio; the field count falls to 0.52 times while bytes fall to 0.61 times, because short field names and envelope characters hold a fixed share of every body.

Numbers Paid

Three numbers grow. First, the edge unit count: 1 to 3. Every unit repeats the aggregation code within itself, meaning the “repeated lines” problem the first lesson measured is reborn inside the edge. Second, per-client redeployment: under a single edge, adding a field affects 3 clients; under backends for frontends, 1. These two are the same tradeoff’s two halves — raising the unit count is the price of narrowing a change’s blast radius.

Third is less visible and the most binding: batch endpoints the service must offer rises from 0 to 2. Meeting the panel’s list in one request requires the gateway to be able to make a batch query against both services; this is a requirement a decision made at the edge writes into the service contracts. Backends for frontends is not a decision that only changes the edge.

Back to the Calculation

K01’s read egress Mbit/s calculation assumed a single client type: 416.67 requests/s and a 480-byte response, 1.60 Mbit/s. The model’s bodies are converted to K01’s scale — the model’s state body is 123 bytes, K01’s tracking response is 480 bytes, the scale is 3.9024 — and T2’s shares are applied.

Under a single edge the edge rate is 583.34 requests/s, the body per request 741 bytes, egress 3.46 Mbit/s: 2.16 times K01’s number. The rate exceeding 416.67 comes from the seller panel’s five requests; the body growing comes from the merged body going to everyone. Under backends for frontends the rate drops to 416.67, the body to 578 bytes, and egress to 1.93 Mbit/s — 1.21 times K01’s 1.60. The gap is permanent and comes from the requirement: fee information was not in K01’s 480 bytes.

Sensitivity separates the two layouts. When the mobile share drops from 0.60 to 0.30 and the difference moves to the web page, the single edge’s numbers do not move at all: 583.34 requests/s and 3.46 Mbit/s. Under backends for frontends, egress rises from 1.93 to 2.07 Mbit/s. This says the single edge is insensitive to client mix — because everyone gets the same body, bandwidth does not change even when the mix does. Insensitivity here is not an advantage: the number is locked to the widest client’s need and does not shrink no matter how far the mobile client’s share rises.

Summary

  • Backends for frontends splits the edge into a separate unit per client type; each unit returns only that client’s fields.
  • Under a single edge, three clients’ 7 requests carry 77 fields and 37 go unused (forty-eight percent); the smallest client carries the most excess.
  • Backends for frontends drops unused fields to 0, requests from 7 to 3, and the body from 1330 to 805 bytes; the entire request drop comes from the seller panel’s list request.
  • Numbers paid: edge units rise from 1 to 3, batch endpoints the service must offer from 0 to 2; in exchange, clients affected by one added field drop from 3 to 1.
  • Back to K01: the 416.67 requests/s and 480-byte, 1.60 Mbit/s egress becomes 583.34 requests/s and 3.46 Mbit/s under a single edge, 416.67 requests/s and 1.93 Mbit/s under backends for frontends.
  • T2 sensitivity separates the two layouts: when the mobile share is halved, the single edge stays fixed at 3.46 Mbit/s, backends for frontends rises from 1.93 to 2.07 — the single edge’s number is locked to the widest client.

Next Step

The four lessons so far treated the edge as one component and wrote every decision inside it: routing, aggregation, offloaded checks, client-specific bodies. One of this lesson’s paid numbers shows this approach’s limit — as the edge unit count grows, the same common code repeats in every unit. One way to share common code is to embed it as a library, but then every unit gets redeployed when its version changes. The next lesson takes up another way: handing common work to a separate process running in the same deployment unit. The ambassador and sidecar patterns name this layout, and what gets measured is the redeployed unit count and the process count per deployment unit.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close