---
title: 'The Client and Server Responsibility Boundary'
source: 'https://academia.sh/en/courses/server-fundamentals/client-and-server-responsibility-boundary'
course: 'Server-Side Fundamentals'
language: en
updated: '2026-08-19T05:19:37+00:00'
license: 'CC BY-SA 4.0'
---

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

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.

```js
// 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"));
```

```bash
#!/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.

```js
// 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"));
```

```bash
#!/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.

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