---
title: 'Synchronous Service Calls'
source: 'https://academia.sh/en/courses/service-architectures/synchronous-service-calls'
course: 'Service Architectures'
language: en
updated: '2026-08-23T14:24:49+00:00'
license: 'CC BY-SA 4.0'
---

# Synchronous Service Calls

Measuring the call crossing the boundary with real processes: the end-to-end round count growing with chain length, a distributed call finishing the same work in fewer rounds, the call amplification that gives the number of internal requests one external request turns into, and the share of entry points left unanswered when a process closes.

The previous lesson tied the boundary to a measure and left a number sitting quietly in the
table: an import turning into a network call. An import edge within the same process is a
function call; the same edge between two deployment units is a network call. This lesson builds
that conversion not with a model but with real, separate operating-system processes, and counts
the difference in rounds.

Timeouts, retries, and distributing the end-to-end budget across a chain were built and measured
in The Application Layer and Service Interaction course; that arithmetic is not repeated here.
The three measures here are: the end-to-end round count by chain length, the number of internal
requests one external request spawns, and the share of requests left unanswered when a process
goes down. A synchronous call is one where the caller waits for the response and folds the result
into its own response.

## The Same Work, Two Layouts

The loan-issuing workflow has four steps: recording the loan, checking membership, checking for
unpaid fees, and allocating a copy. The service below is a single source file; which role it
takes on comes from its command-line arguments. Every step spends the same duration, so the
measured time can be divided by the number of work steps.

```js
// service.mjs — <name> <port> <mode: parallel|sequential> <step> [successor-port...]; long-lived process
import { createServer } from "node:http";
const [name, port, mode, step, ...successors] = process.argv.slice(2);
if (!port) { console.log("usage: node service.mjs <name> <port> <mode> <step> [successors...]"); process.exit(1); }
const WORK = 25;                      // duration of one work step (ms); measured time is divided by this unit
let processed = 0;
const call = async (p) => {           // the successor's status code is checked: an error crosses the boundary
  const y = await fetch(`http://127.0.0.1:${p}/work`);
  if (y.status !== 200) throw new Error(`successor ${p} status ${y.status}`);
  return y.json();
};

createServer(async (request, response) => {
  if (request.url === "/count") { response.end(JSON.stringify({ name, processed })); return; }
  if (request.url === "/reset") { processed = 0; response.end("reset"); return; }
  processed += 1;
  for (let i = 0; i < Number(step); i += 1) await new Promise((c) => setTimeout(c, WORK));
  try {
    let children = [];
    if (mode === "sequential") for (const p of successors) children.push(await call(p));
    else children = await Promise.all(successors.map(call));
    response.end(JSON.stringify({ name, step: Number(step), children }));
  } catch (e) {
    response.statusCode = 502;
    response.end(JSON.stringify({ name, error: e.message }));
  }
}).listen(Number(port));
```

The same four steps will run in two layouts. In the split layout there are four processes: the
loan service calls the membership and catalog services, and the membership service calls the
billing service. In the monolithic layout, all four steps run sequentially in a single process;
the loan endpoint's `step` value is four and there is no successor call at all. A third layout
exists for comparison: the loan service calls its two successors in sequence instead of in
parallel.

The line that checks the status code on a successor call determines the rest of the lesson.
Within a process, a step's failure propagates upward as an exception; across the boundary it is
only a status code. If the caller does not check that code, the failure does not propagate — it
is swallowed: the response looks successful, but the membership check inside it was never done.

## Measurement

The measurement tool runs in three modes. Latency mode sends three requests to each entry point
and finds the round count by dividing the median by the work-step duration; the duration
measurement depends on the environment, the round count does not. Multiplier mode resets the
counters, sends five requests to a single entry point, and sums the requests processed by the
processes. Failure mode sends one request to each entry point and counts how many go unanswered.

**SB2 — external requests are distributed equally across the four entry points.** Rationale: the
affected request share is the traffic weight of the entry points that depend on the service that
goes down; when the weights are unknown, an equal distribution makes the share measurable. When
the real distribution is known, the same calculation is done with weights.

```js
// measure.mjs — <mode: latency|multiplier|failure> [name]; runs while service.mjs processes are up
const WORK = 25;                                 // same work step duration as service.mjs
const UNIT = { billing: 8941, membership: 8942, catalog: 8943, loan: 8944,
  "sequential-loan": 8945, "single-process": 8946 };
const FAILURE_ENTRY = ["billing", "membership", "catalog", "loan"];
const requestUnit = async (p) => {
  const y = await fetch(`http://127.0.0.1:${p}/work`);
  if (y.status !== 200) throw new Error(`status ${y.status}`);
  return y.json();
};
const nodeCount = (a) => 1 + (a.children ?? []).reduce((s, x) => s + nodeCount(x), 0);
const stepCount = (a) => a.step + (a.children ?? []).reduce((s, x) => s + stepCount(x), 0);
const [mode, target] = process.argv.slice(2);

if (mode === "latency") {
  console.log(`${"entry".padEnd(18)}${"processes".padStart(11)}${"network hops".padStart(14)}` +
    `${"work steps".padStart(12)}${"measured rounds".padStart(17)}`);
  let maxOverhead = 0;
  for (const name of Object.keys(UNIT)) {
    const durations = [];
    let tree;
    for (let i = 0; i < 3; i += 1) {
      const t = performance.now();
      tree = await requestUnit(UNIT[name]);
      durations.push(performance.now() - t);
    }
    const median = durations.sort((a, b) => a - b)[1];
    const nodes = nodeCount(tree), rounds = Math.floor(median / WORK);
    maxOverhead = Math.max(maxOverhead, median - rounds * WORK);
    console.log(`${name.padEnd(18)}${String(nodes).padStart(11)}${String(nodes - 1).padStart(14)}` +
      `${String(stepCount(tree)).padStart(12)}${String(rounds).padStart(17)}`);
  }
  console.log(`measured duration = rounds x ${WORK} ms + overhead; overhead in this run stayed ` +
    `under ${Math.ceil(maxOverhead / WORK) * WORK} ms (less than one work step)`);
} else if (mode === "multiplier") {
  for (const name of Object.keys(UNIT)) await fetch(`http://127.0.0.1:${UNIT[name]}/reset`);
  const N = 5;
  for (let i = 0; i < N; i += 1) await requestUnit(UNIT.loan);
  let total = 0;
  for (const name of Object.keys(UNIT)) {
    const { processed } = await (await fetch(`http://127.0.0.1:${UNIT[name]}/count`)).json();
    total += processed;
    if (processed > 0) console.log(`  ${name.padEnd(18)}${String(processed).padStart(8)} requests`);
  }
  console.log(`${N} external requests to the loan entry point -> ${total} processed requests total, ` +
    `call amplification x${total / N} (${total / N - 1} internal requests per external request)`);
} else if (mode === "failure") {
  const dropped = [];
  for (const name of FAILURE_ENTRY) {
    try { await requestUnit(UNIT[name]); } catch { dropped.push(name); }
  }
  console.log(`${target.padEnd(16)}${(dropped.join(",") || "-").padEnd(36)}` +
    `${dropped.length}/${FAILURE_ENTRY.length}`);
}
```

The shell block below brings up six processes, runs the three measurements, then closes three
services one by one, running failure mode after each one and restarting the closed service
before moving on.

```bash
start() {
  case $1 in
    billing)    node service.mjs billing 8941 parallel 1 & ;;
    membership) node service.mjs membership 8942 parallel 1 8941 & ;;
    catalog)    node service.mjs catalog 8943 parallel 1 & ;;
    loan)       node service.mjs loan 8944 parallel 1 8942 8943 & ;;
  esac
}
for s in billing membership catalog loan; do start $s; done
node service.mjs sequential-loan 8945 sequential 1 8942 8943 &
node service.mjs single-process 8946 parallel 4 &
sleep 1

node measure.mjs latency
echo
echo "call amplification:"
node measure.mjs multiplier
echo
printf '%-16s%-36s%s\n' "closed service" "unanswered entries" "affected request share"
for s in billing catalog loan; do
  pkill -f "service.mjs $s" ; sleep 0.3
  node measure.mjs failure $s
  start $s ; sleep 0.5
done
pkill -f "node service.mjs"
```

```
entry               processes  network hops  work steps  measured rounds
billing                     1             0           1                1
membership                  2             1           2                2
catalog                     1             0           1                1
loan                        4             3           4                3
sequential-loan             4             3           4                4
single-process              1             0           4                4
measured duration = rounds x 25 ms + overhead; overhead in this run stayed under 25 ms (less than one work step)

call amplification:
  billing                  5 requests
  membership               5 requests
  catalog                  5 requests
  loan                     5 requests
5 external requests to the loan entry point -> 20 processed requests total, call amplification x4 (3 internal requests per external request)

closed service  unanswered entries                  affected request share
billing         billing,membership,loan             3/4
catalog         catalog,loan                        2/4
loan            loan                                1/4
```

## Chain Length Determines the Round Count

The first three rows read as the chain getting longer. A request to the billing entry point
finishes in one process: zero hops, one round. The membership entry point calls billing: two
processes, one hop, two rounds. The loan entry point runs four processes at once: three hops,
four work steps. **Chained latency** becomes visible here in rounds: the end-to-end round count
is the chain's sequential step count, and the measurement returns exactly that.

The fourth and sixth rows are the lesson's real comparison. The loan entry point finishes its
four work steps in **three** rounds; a single process finishes the same four steps in **four**.
The gain does not come from the split itself — it comes from the opportunity the split opens up:
because the membership branch and the catalog branch do not have to wait on each other, they are
called in parallel, and the end-to-end duration is the larger of the two branches, not their sum.
The fifth row confirms this — the same four processes, the same three hops, but a loan service
that calls its successors in sequence finishes in four rounds. **A distributed call wins back a
round; a sequential call does not.**

The duration measurement depends on the environment, and the cost of a hop over loopback is
small; the entire overhead stayed under one work step. On a real network, the same three hops are
not this cheap, but the round count does not change: the round is a quantity independent of the
run, and the latency budget is multiplied by rounds.

## Multiplier and Affected Share

The call amplification is the second measure. Five external requests to the loan entry point make
the processes run twenty requests in total: a multiplier of four, meaning three internal
requests per external request. This number is a direct result of the boundary decision; the
boundary-crossing import edges the previous lesson measured are exactly the edges that turn into
internal requests at run time. As the number of boundary-crossing edges grows, the multiplier
grows, and every service has to size its capacity not for its own traffic but for traffic
multiplied by that factor.

The last table turns the new failure mode into a number. When the billing process goes down, not
only its own entry point but the membership and loan entry points that depend on it also go
unanswered: three of the four entry points, seventy-five percent of requests under SB2. When
catalog goes down, two entry points are affected; when loan goes down, one. **The affected share
is the dependency closure of the service that goes down**: the service at the bottom of the chain
is the most expensive, because every entry point above it depends on it. In the monolithic layout
this table is a single row — if the process goes down, all requests drop, but there is no such
thing as partial failure.

The triple resolves like this. **What got cheaper:** the four work steps finish in three rounds,
and every service can scale on its own; one service going down leaves the other three entry
points standing. **What got more expensive:** three network hops per request, four processes,
four times the processed-request volume, and a startup arrangement split into four. **The failure
mode that was born:** partial failure. The system is no longer either fully up or fully down;
there is a response rate that varies by entry point, and if the successor's status code is not
checked, that rate never shows up — it turns invisibly into successful responses that did
incomplete work.

## Summary

- The end-to-end round count is the chain's sequential step count: the measurement gave 1, 2, and
  3 rounds for the zero-, one-, and three-hop entry points respectively; overhead stayed under
  one work step.
- The same four work steps finish in 4 rounds in a single process and in 3 rounds across four
  processes; the gain is not the split itself but the parallel call the split enables — the
  layout that calls its successors in sequence finishes in 4 rounds.
- The call amplification is the run-time counterpart of the boundary-crossing edges: 5 external
  requests turned into 20 processed requests, 3 internal requests per external request.
- The affected request share when a process goes down is that service's dependency closure: 3/4
  for billing, 2/4 for catalog, 1/4 for loan.
- The boundary turns a failure from an exception into a status code; if the caller does not check
  the status code, the failure does not propagate — it is swallowed, and a response that looks
  successful contains incomplete work.

## Next Step

All of these measurements lived inside a single assumption: the caller waits until the
successor's work is done. The multiplier, the round count, and the affected share are all born
from that wait — because the loan service waits for the notification to be sent, the notification
service going down drops the loan request. Yet not every step in a workflow has to finish for the
response to be given. The next lesson builds the same workflow a second time: one of the steps on
the response path stops being a call and turns into an event, processed by its consumer on its
own schedule. What will be measured is also clear — the work step that drops off the response
path, the contract burden that appears once the publisher and the consumer are separate
deployment units, and the consistency window between an event being written and its effect
becoming visible.
