---
title: 'Backend for Frontend'
source: 'https://academia.sh/en/courses/service-architectures/backend-for-frontend'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:30+00:00'
license: 'CC BY-SA 4.0'
---

# Backend for Frontend

Measuring a client-specific aggregation layer: the excess data and request count carried by two clients fed from one general endpoint, the effect of separate aggregation layers on those same two numbers, and the repeated logic lines paid for it along with the silent-divergence risk.

The previous lesson collected edge responsibilities into a single place: verification, rate
limiting, and routing no longer live inside every service — they sit in the one layer in front.
That layer's silent assumption is this: whatever the services behind the boundary return goes
straight to the client. But clients connecting to the same endpoint do not want the same
response. The library loan system's web list shows a member's entire loan history along with the
book title, author, and shelf code; the mobile summary screen shows only two numbers and a title.
Both are fed from the same endpoint.

This lesson's job is to measure that situation and compare it against a **backend for frontend**
layout. The pattern's definition was built in The Traffic Layer course and measured there on the
traffic side; what is measured here is the application side: the bytes the client carries, the
requests it makes, and the repetition the aggregation layer costs in code.

The setup's assumptions: **SB10** there are two clients (the web list, the mobile summary) and no
third client. **SB11** catalog, membership, and loan are three separate processes; a catalog
record carries ten fields. **SB12** the measurement does not measure duration; it counts the
request count, the bytes carried, and the field count on the client's network.

## Three Downstream Services and One General Endpoint

Three services run as three separate processes from a single file. Each keeps its own request and
byte counter; these counters will be used to measure the load the downstream side sees.

```js
// bff/service.mjs — three downstream services, one file: node bff/service.mjs catalog 8741
import { createServer } from "node:http";

const [name, port] = [process.argv[2], Number(process.argv[3])];
const BOOK = {
  "978-0": { isbn: "978-0", title: "In Search of Lost Time", author: "M. Proust", translator: "C. Scott Moncrieff",
    publisher: "Chatto and Windus", yearPublished: 1913, pages: 542, genre: "novel", language: "en", shelf: "PQ-2631" },
  "978-1": { isbn: "978-1", title: "Silent House", author: "O. Pamuk", translator: "R. Finn",
    publisher: "Knopf", yearPublished: 1983, pages: 320, genre: "novel", language: "en", shelf: "PL-248" },
  "978-2": { isbn: "978-2", title: "Introduction to Algorithms", author: "T. Cormen", translator: null,
    publisher: "MIT", yearPublished: 2009, pages: 1312, genre: "textbook", language: "en", shelf: "QA-76" },
  "978-3": { isbn: "978-3", title: "Steppenwolf", author: "H. Hesse", translator: "B. Creighton",
    publisher: "Picador", yearPublished: 1927, pages: 224, genre: "novel", language: "en", shelf: "PT-2617" },
};
const MEMBER = { memberNo: "U-41", firstName: "Elena", lastName: "Cross", email: "e.cross@example.test",
  phone: "+90-555-0000", address: "Kordon Cad. 12, Izmir", memberSince: "2019-04-02",
  memberType: "researcher", penaltyBalance: 0, cardStatus: "active" };
const LOAN = [
  { isbn: "978-0", borrowed: "2026-02-10", dueDate: "2026-03-10", extensions: 1 },
  { isbn: "978-1", borrowed: "2026-02-24", dueDate: "2026-03-24", extensions: 0 },
  { isbn: "978-2", borrowed: "2026-02-14", dueDate: "2026-03-14", extensions: 2 },
  { isbn: "978-3", borrowed: "2026-03-02", dueDate: "2026-04-01", extensions: 0 },
];

let requests = 0, bytes = 0;                        // requests and body bytes sent, per service
const SERVE = { catalog: (u) => BOOK[u.split("/")[2]], membership: () => MEMBER, loan: () => LOAN };

createServer((req, res) => {
  if (req.url === "/count") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(JSON.stringify({ name, requests, bytes }));
  }
  const body = JSON.stringify(SERVE[name](req.url));
  requests += 1;
  bytes += Buffer.byteLength(body);
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(body);
}).listen(port, "127.0.0.1", () => console.log(`${name} 127.0.0.1:${port}`));
```

The general endpoint merges the membership and loan records and returns them as they are. It does
not add catalog data, because the catalog sits behind another boundary and this endpoint was
written before that one. The result is this: any client that wants to show a book title has to go
to the catalog service itself after receiving the list.

```js
// bff/general-endpoint.mjs — single general endpoint 8744: both clients call here, get the same body
import { createServer } from "node:http";

const GET = async (u) => (await fetch(u)).json();

createServer(async (req, res) => {
  const memberNo = req.url.split("/")[2];
  const member = await GET("http://127.0.0.1:8742/member/" + memberNo);
  const loan = await GET(`http://127.0.0.1:8743/member/${memberNo}/loans`);
  const body = JSON.stringify({ member, loan });
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(body);
}).listen(8744, "127.0.0.1", () => console.log("general-endpoint 127.0.0.1:8744"));
```

## Two Clients, Two Aggregation Layers

In the second implementation, every client has its own aggregation layer. The web layer assembles
the list and computes the overdue days; the mobile layer extracts two numbers and a title from
the same data. Both are separate deployment units, and neither uses the other's code.

```js
// bff/bff-web.mjs — aggregation layer for the web list, 8745
import { createServer } from "node:http";

const TODAY = "2026-03-16";
const CATALOG = "http://127.0.0.1:8741/book/";
const MEMBERSHIP = "http://127.0.0.1:8742/member/";
const LOAN = "http://127.0.0.1:8743/member/";
const GET = async (u) => (await fetch(u)).json();
const dayDiff = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86400000);

createServer(async (req, res) => {
  const memberNo = req.url.split("/")[2];
  const member = await GET(MEMBERSHIP + memberNo);
  const loans = await GET(`${LOAN}${memberNo}/loans`);
  const books = await Promise.all(loans.map((o) => GET(CATALOG + o.isbn)));
  const rows = loans.map((o, i) => ({
    title: books[i].title, author: books[i].author, shelf: books[i].shelf,
    due: o.dueDate, overdueDays: Math.max(0, dayDiff(TODAY, o.dueDate)),
  }));
  const body = JSON.stringify({ reader: `${member.firstName} ${member.lastName}`, rows });
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(body);
}).listen(8745, "127.0.0.1", () => console.log("bff-web 127.0.0.1:8745"));
```

```js
// bff/bff-mobile.mjs — aggregation layer for the mobile summary, 8746
import { createServer } from "node:http";

const TODAY = "2026-03-16";
const CATALOG = "http://127.0.0.1:8741/book/";
const MEMBERSHIP = "http://127.0.0.1:8742/member/";
const LOAN = "http://127.0.0.1:8743/member/";
const GET = async (u) => (await fetch(u)).json();
const dayDiff = (a, b) => Math.round((Date.parse(a) - Date.parse(b)) / 86400000);

createServer(async (req, res) => {
  const memberNo = req.url.split("/")[2];
  const member = await GET(MEMBERSHIP + memberNo);
  const loans = await GET(`${LOAN}${memberNo}/loans`);
  const overdue = loans.filter((o) => dayDiff(TODAY, o.dueDate) > 0).length;
  const nearest = loans.slice().sort((a, b) => Date.parse(a.dueDate) - Date.parse(b.dueDate))[0];
  const book = await GET(CATALOG + nearest.isbn);
  const body = JSON.stringify({ reader: member.firstName, overdue,
    nearest: { title: book.title, daysLeft: dayDiff(nearest.dueDate, TODAY) } });
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(body);
}).listen(8746, "127.0.0.1", () => console.log("bff-mobile 127.0.0.1:8746"));
```

## Measurement

The measurement script runs four cases: two clients, two implementations. In each case it
collects the number of requests the client makes, the bytes it receives, and the number of leaf
fields in the body it receives; it prunes the body down to the names the client actually reads and
works out the unused bytes. The last column is the leak measure: how many of the names the client
reads are the downstream services' own field names.

```js
// bff/measure.mjs — two clients x two implementations: requests, bytes, excess data, repeated lines
import { readFileSync } from "node:fs";

const get = async (u) => {
  const m = await (await fetch(u)).text();
  return { bytes: Buffer.byteLength(m), data: JSON.parse(m) };
};
const COUNTER = [8741, 8742, 8743].map((p) => `http://127.0.0.1:${p}/count`);
const total = async () => (await Promise.all(COUNTER.map(get)))
  .reduce((s, x) => s + x.data.requests, 0);

// Field names the clients actually read to put on screen
const FIELD = {
  "web/general endpoint": ["firstName", "lastName", "isbn", "dueDate", "author", "shelf"],
  "mobile/general endpoint": ["firstName", "isbn", "dueDate"],
  "web/bff": ["reader", "rows"],
  "mobile/bff": ["reader", "overdue", "nearest"],
};
const SCENARIO = {
  "web/general endpoint": async () => {                    // general endpoint + one catalog call per book
    const g = await get("http://127.0.0.1:8744/member/U-41/summary");
    const books = await Promise.all(g.data.loan.map((o) => get(`http://127.0.0.1:8741/book/${o.isbn}`)));
    return { requests: 1 + books.length, bytes: g.bytes + books.reduce((s, x) => s + x.bytes, 0),
      body: { ...g.data, books: books.map((x) => x.data) } };
  },
  "mobile/general endpoint": async () => {                  // same general endpoint + only the nearest book
    const g = await get("http://127.0.0.1:8744/member/U-41/summary");
    const y = g.data.loan.slice().sort((a, b) => Date.parse(a.dueDate) - Date.parse(b.dueDate))[0];
    const books = await get(`http://127.0.0.1:8741/book/${y.isbn}`);
    return { requests: 2, bytes: g.bytes + books.bytes, body: { ...g.data, books: [books.data] } };
  },
  "web/bff": async () => {
    const g = await get("http://127.0.0.1:8745/member/U-41/list");
    return { requests: 1, bytes: g.bytes, body: g.data };
  },
  "mobile/bff": async () => {
    const g = await get("http://127.0.0.1:8746/member/U-41/summary");
    return { requests: 1, bytes: g.bytes, body: g.data };
  },
};

const leaf = (v) => Array.isArray(v) ? v.reduce((s, e) => s + leaf(e), 0)
  : v && typeof v === "object" ? Object.values(v).reduce((s, e) => s + leaf(e), 0) : 1;
const prune = (v, k) => {                           // keep only the names the client reads
  if (Array.isArray(v)) return v.map((e) => prune(e, k));
  if (v && typeof v === "object") {
    const o = {};
    for (const [a, d] of Object.entries(v)) {
      if (k.has(a)) o[a] = d;
      else { const sub = prune(d, k); if (sub && Object.keys(sub).length) o[a] = sub; }
    }
    return o;
  }
  return null;
};
const meaningful = (file) => readFileSync(file, "utf8").split("\n")
  .map((s) => s.trim()).filter((s) => s && !s.startsWith("//") && /[A-Za-z]/.test(s));

// Field names the downstream services produce: the basis of the leak measure
const raw = await Promise.all(["8742/member/U-41", "8743/member/U-41/loans", "8741/book/978-0"]
  .map((y) => get(`http://127.0.0.1:${y}`)));
const DOWNSTREAM = new Set(raw.flatMap((h) => Object.keys(Array.isArray(h.data) ? h.data[0] : h.data)));

const rows = [];
for (const [name, run] of Object.entries(SCENARIO)) {
  const before = await total();
  const s = await run();
  const after = await total();
  const fields = new Set(FIELD[name]);
  rows.push({ name, body: s.body, requests: s.requests, internal: after - before, bytes: s.bytes,
    incoming: leaf(s.body), used: leaf(prune(s.body, fields)),
    excess: s.bytes - Buffer.byteLength(JSON.stringify(prune(s.body, fields))),
    leaked: FIELD[name].filter((a) => DOWNSTREAM.has(a)).length });
}

const B = ["client requests", "downstream requests", "received B", "incoming leaves", "used", "excess B", "leaked names"];
console.log(`${"client/implementation".padEnd(26)}${B.map((b) => b.padStart(22)).join("")}`);
for (const s of rows) {
  console.log(`${s.name.padEnd(26)}${[s.requests, s.internal, s.bytes, s.incoming, s.used, s.excess, s.leaked]
    .map((v) => String(v).padStart(22)).join("")}`);
}

console.log(`\nmobile/bff body: ${JSON.stringify(rows.at(-1).body)}`);
const w = meaningful("bff/bff-web.mjs"), m = meaningful("bff/bff-mobile.mjs");
const shared = w.filter((s) => m.includes(s));
console.log(`\nbff-web meaningful lines = ${w.length}, bff-mobile = ${m.length}, ` +
  `identical in both = ${shared.length}`);
console.log(`general endpoint file = ${meaningful("bff/general-endpoint.mjs").length} lines, ` +
  `aggregation layer files = ${w.length + m.length} lines`);
console.log(`downstream service field names = ${DOWNSTREAM.size}; aggregation layer = 2 deployment units`);
```

```bash
for s in "service.mjs catalog 8741" "service.mjs membership 8742" "service.mjs loan 8743" \
         "general-endpoint.mjs" "bff-web.mjs" "bff-mobile.mjs"; do
  node bff/$s > /dev/null &
done
for p in 8741 8742 8743 8744 8745 8746; do
  curl -s --retry 30 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:$p/count
done
node bff/measure.mjs
kill $(jobs -p)
```

```
client/implementation            client requests   downstream requests            received B       incoming leaves                  used              excess B          leaked names
web/general endpoint                           5                     6                  1365                    66                    22                   916                     6
mobile/general endpoint                        2                     3                   795                    36                    10                   567                     3
web/bff                                        1                     6                   447                    21                    21                     0                     0
mobile/bff                                     1                     3                    89                     4                     4                     0                     0

mobile/bff body: {"reader":"Elena","overdue":2,"nearest":{"title":"In Search of Lost Time","daysLeft":-6}}

bff-web meaningful lines = 19, bff-mobile = 19, identical in both = 13
general endpoint file = 10 lines, aggregation layer files = 38 lines
downstream service field names = 23; aggregation layer = 2 deployment units
```

## Reading the Numbers

The first two rows give the cost of the single general endpoint. Building the web list screen
takes five requests and receives 1365 bytes; 916 of those bytes, that is, sixty-seven percent,
never reach the screen at all. The mobile summary screen makes two requests to show two numbers
and a title, receives 795 bytes, and 567 of those go unused. On the field side the ratio is
sharper still: the mobile client receives thirty-six leaf values and reads ten of them.

The bottom two rows show the aggregation layer's effect. Client requests drop from 5 and 2 to 1.
Received bytes drop from 1365 to 447 on the web side, and from 795 to 89 on the mobile side — the
data carried for the mobile screen shrinks to about a ninth. Excess bytes are 0 in both cases,
because the layer builds the body exactly in the shape the screen wants.

The downstream request column, though, does not change: 6 in both implementations on the web
side, 3 on the mobile side. This is the pattern's most commonly misunderstood point. The
aggregation layer does not reduce the load on the downstream services; it does the same work, it
just changes **where** that work happens. Five round trips move from the client's network to the
internal network. The gain is on the client's network, and there a round trip costs far more than
an in-server call.

The leaked-names column measures coupling. In the general-endpoint layout, all six of the names
the web client reads are the downstream services' own field names; the `dueDate` name comes from
the loan service's vocabulary and reaches all the way down into the client's code. In the
aggregation-layer layout, this number is 0: the client sees the screen's own vocabulary — `due`,
`overdueDays`, `nearest`. When the loan service renames its field, the client code in the first
layout has to be fixed and republished; in the second layout, the fix stays inside the layer.

## Repeated Logic and Silent Divergence

The cost is in the last three rows. The general-endpoint file is ten meaningful lines; the two
aggregation layers are thirty-eight lines together. Thirteen of those are identical, word for
word, in both files: the three service addresses, the `GET` helper, `dayDiff`, the `TODAY`
constant, and the member and loan calls. So setting up one layer per client means copying the
same logic as many times as there are clients. When a third client arrives, thirteen lines will
be written a third time.

The failure mode this repetition creates is not the copy itself — it is only one of the copies
getting updated. The loan service renames its `dueDate` field to `deadline`, and only the web
layer is updated.

```bash
sed 's/dueDate/deadline/g' bff/service.mjs > bff/service-v2.mjs   # loan field renamed
sed 's/dueDate/deadline/g' bff/bff-web.mjs > bff/bff-web-v2.mjs  # only the web layer was updated
node bff/service.mjs catalog 8741 > /dev/null &
node bff/service.mjs membership 8742 > /dev/null &
node bff/service-v2.mjs loan 8743 > /dev/null &
node bff/bff-web-v2.mjs > /dev/null &
node bff/bff-mobile.mjs > /dev/null &
for p in 8741 8742 8743 8745 8746; do
  curl -s --retry 30 --retry-connrefused --retry-delay 0 -o /dev/null http://127.0.0.1:$p/count
done
echo "web    $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8745/member/U-41/list) $(curl -s http://127.0.0.1:8745/member/U-41/list | head -c 96)"
echo "mobile $(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8746/member/U-41/summary) $(curl -s http://127.0.0.1:8746/member/U-41/summary)"
kill $(jobs -p)
```

```
web    200 {"reader":"Elena Cross","rows":[{"title":"In Search of Lost Time","author":"M. Proust","shelf":"
mobile 200 {"reader":"Elena","overdue":0,"nearest":{"title":"In Search of Lost Time","daysLeft":null}}
```

Both layers return 200. The web list is correct; the mobile summary gives `overdue` as 0 instead
of 2, and `daysLeft` as `null` instead of -6. Because the unread field is `undefined`, the date
comparison silently produced a wrong result, and no error was logged anywhere. In the
single-general-endpoint layout, the same change would have broken both clients at once, and that
is a failure that is easier to notice. In the aggregation-layer layout, the failure shows up as
**divergence between clients**: one screen is correct, the other is silently wrong.

The rule that follows concerns the pattern's ownership. The aggregation layer is a deployment unit
belonging to the team that writes the screen, not to the downstream service; when the downstream
service's contract changes, a fix is needed once per layer, and the only thing that shows all
those fixes were made is testing the layers against the contract.

## Summary

- In the single-general-endpoint layout, the web list carries 5 requests and 1365 bytes, the
  mobile summary 2 requests and 795 bytes; 916 and 567 of those bytes never reach the screen.
- The client-specific aggregation layer brings the request count down to 1, received bytes down
  to 447 and 89; excess bytes become 0 for both clients.
- Requests reaching the downstream services do not change (6 and 3): the layer does not reduce
  the load, it moves the round trips from the client's network to the internal network.
- Coupling measure: at the general endpoint, all 6 of the names the client reads are downstream
  field names; at the aggregation layer, 0.
- Code paid: the two layers are 38 meaningful lines, 13 of them identical; plus 2 new deployment
  units.
- Failure mode born: when a downstream field is renamed and only one layer is updated, the two
  clients silently diverge — web returns 200 and correct, mobile returns 200 and `overdue: 0`.

## Next Step

The aggregation layer, and the gateway in front of it, are the same kind of solution: taking a
responsibility out of the service code and putting it in a separate place. What both of them took
on had to do with **shape** — which field goes to whom, how the body is built. But another pile
of things that has nothing to do with shape still sits inside the service code: the retry loop,
the timeout duration, the transport's security, and the counters that collect each call's
metrics. In every one of the three layers written through this lesson, the `fetch` call is bare;
there is no timeout, no retry, no counter. The next lesson takes on the layout that pulls this
work out of the service code and moves it into a separate process, and builds the measure on the
code side: how many lines got deleted from the application code, what responsibility is left
behind, and what running in a separate process cost.
