Skip to content
academia.sh

Lesson 02 / 16

The Client and Server Responsibility Boundary

The criteria that decide the choice when the same computation can be done on either side; bytes transferred, number of network round trips, and how binding the decision is are measured and compared.

Contents

The previous lesson established that the server is the source of truth through three responsibilities: persistence, business rules, and integration. This does not mean every computation happens on the server. In the library application, many operations — filtering a list, computing an amount, normalizing a search term — can be written on either side, and both work correctly.

This lesson examines where that boundary runs using three measures: bytes transferred, number of network round trips, and how binding the decision is. The first two are measurable; the third is not, but it is the most decisive.

Same Result, Two Paths: Data Transferred

The library’s catalog holds five thousand books. The user wants to see the first page of books on the topic of “networks.” There are two paths: the server sends the whole catalog and the client filters; or the server filters, pages, and sends only the slice to be displayed. The server below serves both.

// catalog-server.mjs — serves the same catalog in two forms: the whole thing, and a filtered page
import { createServer } from "node:http";

const TOPICS = ["algorithms", "databases", "networks", "operating-systems", "compilers"];
const CATALOG = Array.from({ length: 5000 }, (_, i) => ({
  isbn: `978-0${(1000000 + i).toString()}`,
  title: `Book ${i + 1}: a study of ${TOPICS[i % 5]}`,
  topic: TOPICS[i % 5],
  year: 1970 + (i % 55),
  shelf: `S-${Math.floor(i / 50) + 1}`,
}));

createServer((req, res) => {
  res.sendDate = false;
  const address = new URL(req.url, "http://local");
  res.setHeader("content-type", "application/json; charset=utf-8");

  if (address.pathname === "/books/all") {
    return res.writeHead(200).end(JSON.stringify(CATALOG));
  }
  if (address.pathname === "/books") {
    const topic = address.searchParams.get("topic");
    const page = Number(address.searchParams.get("page") ?? 1);
    const pageSize = 20;
    const filtered = topic ? CATALOG.filter((k) => k.topic === topic) : CATALOG;
    const slice = filtered.slice((page - 1) * pageSize, page * pageSize);
    return res.writeHead(200).end(JSON.stringify({ total: filtered.length, page, books: slice }));
  }
  res.writeHead(404).end(JSON.stringify({ error: "route_not_found" }));
}).listen(8424, "127.0.0.1", () => console.log("catalog 127.0.0.1:8424"));
#!/usr/bin/env bash
# Measures the bytes and time carried by the two approaches that produce the same result.
node catalog-server.mjs & server=$!
sleep 0.5
curl -sS -o /dev/null 'http://127.0.0.1:8424/books?topic=networks'   # warm-up

FORMAT='  body %{size_download} B   first byte %{time_starttransfer} s\n'
echo "--- whole catalog moves to the client (filtering on the client) ---"
curl -sS -o /dev/null -w "$FORMAT" 'http://127.0.0.1:8424/books/all'
echo "--- filtering and paging on the server ---"
curl -sS -o /dev/null -w "$FORMAT" 'http://127.0.0.1:8424/books?topic=networks&page=1'

echo "--- first lines of the two responses ---"
curl -sS 'http://127.0.0.1:8424/books?topic=networks&page=1' | head -c 220; echo
kill "$server"
catalog 127.0.0.1:8424
--- whole catalog moves to the client (filtering on the client) ---
  body 579494 B   first byte 0.002180 s
--- filtering and paging on the server ---
  body 2191 B   first byte 0.000734 s
--- first lines of the two responses ---
{"total":1000,"page":1,"books":[{"isbn":"978-01000002","title":"Book 3: a study of networks","topic":"networks","year":1972,"shelf":"S-1"},{"isbn":"978-01000007","title":"Book 8: a study of networks","topic":"networks",

Time fields depend on the machine and the load at that moment; they change on every run. What stays fixed is the byte ratio: 579,494 bytes against 2,191 bytes, about 264 times. The user will see twenty lines on screen; on the first path, the entire five thousand records were transferred for those twenty lines.

The difference is not only bandwidth. Once the whole catalog lands on the client, it is also held in memory, parsed, and walked again on every filter operation; as the catalog grows, this cost is paid on the client’s weakest device. On the other path, by contrast, every filter change means a new request: when the user changes the topic, a network call goes out and the result is awaited.

The criterion is the ratio between these two costs. If the data set is too large to fit on screen, filtering belongs on the server. If the data set is small and already downloaded — a member’s six open loans, say — repeatedly requesting the same list from the server produces unnecessary round trips.

The Item Measurement Cannot Decide: Whether the Decision Is Binding

The decision above can be made on performance grounds; either option produces the correct result. For some computations, though, there is no choice, and the reasoning does not rest on measurement.

The late fee is such a computation. The loan period is fourteen days, and a per-day fee is charged for every day of delay. The computation is two multiplications and a subtraction; the client could do it too. The server below does not read the amount the client sends or the time the client reports.

// return-server.mjs — computes the late fee itself, without looking at what the client reports
import { createServer } from "node:http";

const DAY = 86_400_000;
const LOAN_DAYS = 14;      // loan period
const DAILY_FEE = 2.5;     // fee per day of delay

// Seed data: a loan created 20 days ago as the server comes up.
const LOANS = new Map([["U-1002:978-0201896831", { issuedAt: Date.now() - 20 * DAY }]]);

const readBody = (req) =>
  new Promise((resolve, reject) => {
    let data = "";
    req.on("data", (chunk) => (data += chunk));
    req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); } });
  });

createServer(async (req, res) => {
  res.sendDate = false;
  res.setHeader("content-type", "application/json; charset=utf-8");
  const body = await readBody(req);                    // body.amount and body.now are not read
  const record = LOANS.get(`${body.member}:${body.isbn}`);
  if (!record) return res.writeHead(404).end(JSON.stringify({ error: "loan_not_found" }));

  const dueDate = record.issuedAt + LOAN_DAYS * DAY;
  const lateDays = Math.max(0, Math.floor((Date.now() - dueDate) / DAY));
  const amount = Number((lateDays * DAILY_FEE).toFixed(2));
  res.writeHead(200).end(JSON.stringify({
    member: body.member, lateDays, amount,
    clientReported: body.amount ?? null,
  }));
}).listen(8425, "127.0.0.1", () => console.log("return 127.0.0.1:8425"));
#!/usr/bin/env bash
# Does the server's response change when the client-reported amount and time change?
node return-server.mjs & server=$!
sleep 0.5

send() {
  printf '%-62s\n  -> ' "$1"
  curl -sS -w '\n' -X POST -H 'content-type: application/json' -d "$1" http://127.0.0.1:8425/return
}

send '{"member":"U-1002","isbn":"978-0201896831"}'
send '{"member":"U-1002","isbn":"978-0201896831","amount":0}'
send '{"member":"U-1002","isbn":"978-0201896831","amount":0,"now":"2020-01-01T00:00:00Z"}'
kill "$server"
return 127.0.0.1:8425
{"member":"U-1002","isbn":"978-0201896831"}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":null}
{"member":"U-1002","isbn":"978-0201896831","amount":0}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":0}
{"member":"U-1002","isbn":"978-0201896831","amount":0,"now":"2020-01-01T00:00:00Z"}
  -> {"member":"U-1002","lateDays":6,"amount":15,"clientReported":0}

All three requests got the same result: six days late, a fee of fifteen units. The second request reported a zero amount, and the third also set its own clock back six years. The server’s response did not change, because the server merely recorded both inputs and computed the amount from its own data and its own clock.

The principle at work here is not a performance principle but a trust boundary principle: the server treats no value as binding unless it can derive that value from data under its own control. This list includes amounts, clock readings, identity claims, prices, and authorization information coming from the client. The client can send all of these; the server takes all of them as input and none of them as a decision.

The client performing the same computation is still useful. When a member opens the return screen, they see the amount immediately and are not surprised. This computation is an estimate, and when it conflicts with the server’s computation, the server’s holds. The same rule appeared in the first lesson with the loan limit: the client displays, the server binds.

The Reverse Direction: Computation That Should Not Move to the Server

The boundary does not run one way only. Moving a computation to the server unnecessarily is also a design mistake, and its cost is measured in the number of round trips.

As the user types in the catalog search box, the search term is corrected: leading and trailing spaces are dropped, the text is lowercased, and internal spaces are collapsed to one. Case folding is locale-sensitive, and that can look like a reason to do the correction on the server. The measurement below shows the cost.

// round-trip-cost.mjs — the cost of going to the server for every keystroke in a search box
import { createServer } from "node:http";

const KEYSTROKES = ["a", "al", "alg", "algo", "algor", "algori", "algorit", "algorithm"];
const normalize = (text) => text.trim().toLocaleLowerCase("en-US").replace(/\s+/g, " ");

const server = createServer((req, res) => {
  res.sendDate = false;
  const q = new URL(req.url, "http://local").searchParams.get("q") ?? "";
  res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ q: normalize(q) }));
});

await new Promise((resolve) => server.listen(8426, "127.0.0.1", resolve));
await fetch("http://127.0.0.1:8426/normalize?q=warmup");            // warm-up trip

const t0 = performance.now();
for (const key of KEYSTROKES) await (await fetch(`http://127.0.0.1:8426/normalize?q=${key}`)).json();
const serverSide = performance.now() - t0;

const t1 = performance.now();
for (const key of KEYSTROKES) normalize(key);
const clientSide = performance.now() - t1;

server.close();

console.log(`keystrokes: ${KEYSTROKES.length}`);
console.log(`client-side normalize : ${clientSide.toFixed(3)} ms`);
console.log(`server-side normalize : ${serverSide.toFixed(3)} ms  (over the loopback interface)`);
console.log("");
console.log("total wait once a network round trip is added (one trip per keystroke):");
console.log("  " + "trip".padEnd(10) + "server-side".padStart(12) + "client-side".padStart(12));
for (const rtt of [0, 20, 60, 150]) {
  const total = serverSide + KEYSTROKES.length * rtt;
  console.log("  " + `${rtt} ms`.padEnd(10) + `${total.toFixed(1)} ms`.padStart(12) +
    `${clientSide.toFixed(1)} ms`.padStart(12));
}
keystrokes: 8
client-side normalize : 0.004 ms
server-side normalize : 12.858 ms  (over the loopback interface)

total wait once a network round trip is added (one trip per keystroke):
  trip       server-side client-side
  0 ms           12.9 ms      0.0 ms
  20 ms         172.9 ms      0.0 ms
  60 ms         492.9 ms      0.0 ms
  150 ms       1212.9 ms      0.0 ms

Time fields are machine-dependent; the ratio holds. Even over the loopback interface the difference is three orders of magnitude, because a local function call and an HTTP request are not operations of the same size. The table shows how the difference grows once a network round trip is added: at an eighty-millisecond round trip, eight keystrokes produce a wait of almost one second.

There is no gain in moving this computation to the server, because the result is not binding: getting the term correction wrong does not corrupt data, it only changes the search result. And the server already has to perform the same correction on its own side — it will not hand the incoming term to the query as-is. So the correction exists on both sides; it speeds up the client’s response and secures the server’s correctness. The same computation existing on both sides is not duplication — it is what two different jobs require.

Three Questions That Draw the Boundary

The three measurements above reduce to a single decision flow. Which side a computation belongs to is asked in this order:

Is the computation binding? If a wrong result gets written to data or to money, the computation belongs on the server, and the discussion ends there. A copy of the same computation may still exist on the client, but only for display.

Where does the input sit? If the data the computation needs is on the server and it is large, taking the computation to the data is cheaper than taking the data to the computation — that is what filtering the catalog is. If the data is already downloaded to the client, the reverse holds.

How many times is it done? When a computation that repeats multiple times per user interaction moves to the server, its cost is multiplied by the number of round trips. One request per keystroke means several round trips per second.

The three questions can conflict. A search box is both repeated frequently and has its input on the server: in that case, the computation itself stays on the server, but request frequency is reduced with debouncing. The conflict is resolved not by canceling out one of the questions but by reducing both costs together.

Summary

  • The choice between two placements that produce the same result is measurable: moving filtering to the server on a five-thousand-record catalog reduced the body transferred from 579,494 bytes to 2,191 bytes.
  • Where filtering happens depends on the size of the data set; for a small, already-downloaded list, going to the server on every interaction produces unnecessary round trips.
  • Binding computations are not chosen by measurement: the server takes no value it cannot derive from its own data and its own clock as a decision; the amount and time the client reported did not change the response.
  • The cost of moving a presentation computation to the server is multiplied by the number of round trips; the measured difference for eight keystrokes is three orders of magnitude over the loopback interface, and climbs to seconds over a real round trip.
  • The decision is made with three questions: is the computation binding, where does the input sit, how many times is it repeated.

Next Step

Up to this point, “the server” has been treated as a single program: one process that listens on a port, reads the request, enforces the rule, and writes the response. In real deployments, this work is split across multiple components. The component serving the library application’s cover images and interface files does not need to be the same one that runs the loan rule; a component’s only job up front might even be forwarding the request to the right place. The next lesson builds that division of labor: serving static files, splitting requests by path, and handing a request off to another server are each run separately and demonstrated.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close