---
title: 'Serverless Approach'
source: 'https://academia.sh/en/courses/service-architectures/serverless-approach'
course: 'Service Architectures'
language: en
updated: '2026-08-23T07:00:29+00:00'
license: 'CC BY-SA 4.0'
---

# Serverless Approach

Measuring the constraints of the event-triggered function model: the extra store calls that come from being unable to hold anything in memory between two calls, a per-call time limit splitting the workflow, and the last local calls left inside a process turning into outside calls.

The previous lesson's five processes were long-lived: started once, staying up whether a
request arrived or not, holding member records in their own memory. This lesson shrinks the
deployment unit by one more step and removes long-livedness. The unit is no longer a process,
it is **a function that exists only when an event triggers it**.

This arrangement is called **serverless**. The definition of the style and its initial setup
cost were covered in the Architectural Styles course; what is measured here is the function
model's **runtime constraints**: nothing staying in memory between two calls, a time limit on
every call, and the last local calls left inside a process going outside too.

**AO6.** The function runner is a local model: for every event it starts a new process with
`node` and applies a fixed time limit to that process. The model carries three properties —
memory is not shared between calls, an exceeded time limit cuts the work off mid-way, and
everything outside the function is reached over the network.

## Runner and Store

State has to sit somewhere. Since functions cannot hold it, the single long-lived process is
a key-value store.

```js
// store-service.mjs — single long-lived process holding state between functions
import { createServer } from "node:http";
const port = Number(process.argv[2]);
if (!port) { console.log("usage: node store-service.mjs <port>"); process.exit(0); }
const box = { "book:1": { status: "on_shelf" },
               "member:4": { name: "Derek", openLoans: 1, limit: 5, balance: 0 },
               overdue: ["o1", "o2", "o3"], processed: [] };
let calls = 0;
const counts = {};
createServer(async (request, response) => {
  const u = new URL(request.url, "http://y");
  const a = u.searchParams.get("a");
  const track = () => { calls += 1; counts[a] = (counts[a] ?? 0) + 1; };
  if (u.pathname === "/get") { track(); return response.end(JSON.stringify(box[a] ?? null)); }
  if (u.pathname === "/put") {
    track();
    let g = "";
    for await (const p of request) g += p;
    box[a] = JSON.parse(g);
    return response.end("{}");
  }
  if (u.pathname === "/counter") {
    const y = { calls, member: counts["member:4"] ?? 0 };
    if (u.searchParams.get("reset")) { calls = 0; for (const k in counts) delete counts[k]; }
    return response.end(JSON.stringify(y));
  }
  response.statusCode = 404;
  response.end("{}");
}).listen(port, () => console.log(`store ${port}`));
```

```js
// runner.mjs — function invoker: a new process for each event, a time limit per call
import { spawnSync } from "node:child_process";
export const TIMEOUT_MS = 400;
export function call(fn, ...event) {
  const s = spawnSync("node", [fn, ...event], { encoding: "utf8", timeout: TIMEOUT_MS });
  return { cut: s.signal !== null, output: s.stdout.trim() };
}
```

## Four Functions

Each function takes its event from the command line, does its job, prints the result, and
dies. None of them can leave anything for the next call.

```js
// function-eligibility.mjs — event-triggered function; starts with an empty memory every call
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${process.argv[2]}`)).json();
console.log(JSON.stringify({ eligible: member.openLoans < member.limit }));
```

```js
// function-mark.mjs — marks the book on loan, increases the member's counter
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const [bookNo, memberNo] = process.argv.slice(2);
const write = (a, d) => fetch(`http://127.0.0.1:${S}/put?a=${a}`, { method: "POST", body: JSON.stringify(d) });
const book = await (await fetch(`http://127.0.0.1:${S}/get?a=book:${bookNo}`)).json();
if (book.status !== "on_shelf") { console.log(JSON.stringify({ error: "book not on shelf" })); process.exit(0); }
await write(`book:${bookNo}`, { status: "on_loan" });
const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json();
member.openLoans += 1;
await write(`member:${memberNo}`, member);
console.log(JSON.stringify({ status: "on_loan" }));
```

```js
// function-fee.mjs — calculates the late fee and writes it to the member's balance
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const [memberNo, days] = process.argv.slice(2).map(Number);
const fee = days > 0 ? days * 2 : 0;
if (fee > 0) {
  const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json();
  member.balance += fee;
  await fetch(`http://127.0.0.1:${S}/put?a=member:${memberNo}`, { method: "POST", body: JSON.stringify(member) });
}
console.log(JSON.stringify({ fee }));
```

```js
// function-notify.mjs — reads the member's name from the store and prints the notification
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const [memberNo, text] = process.argv.slice(2);
const member = await (await fetch(`http://127.0.0.1:${S}/get?a=member:${memberNo}`)).json();
console.log(`notification -> ${member.name}: ${text}`);
```

```js
// flow.mjs — loan workflow split into four function calls; each call a new process
import { call } from "./runner.mjs";
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const counter = async () => (await (await fetch(`http://127.0.0.1:${S}/counter?reset=1`)).json());
await counter();
const [bookNo, memberNo, days] = ["1", "4", "3"];
let fn = 0;
const c = (name, ...event) => { fn += 1; return JSON.parse(call(name, ...event).output || "null"); };
const e = c("function-eligibility.mjs", memberNo);
if (e.eligible) {
  c("function-mark.mjs", bookNo, memberNo);
  const { fee } = c("function-fee.mjs", memberNo, days);
  fn += 1;
  console.log(call("function-notify.mjs", memberNo, `book ${bookNo}`).output);
  console.log(`loan issued: book ${bookNo} -> member ${memberNo}, fee ${fee}`);
}
const s = await counter();
console.log(`function call: ${fn}   new process: ${fn}   store call: ${s.calls}   member:4 access: ${s.member}`);
```

```sh
# start.sh — the single long-lived process holding state comes up, then the workflow runs
node store-service.mjs 8796 >store.log 2>&1 & echo $! >store.pid
for i in $(seq 40); do curl -s -o /dev/null "http://127.0.0.1:8796/" && break; sleep 0.2; done
echo "long-lived process: 1 (store)"
STORE=8796 node flow.mjs
```

```
long-lived process: 1 (store)
notification -> Derek: book 1
loan issued: book 1 -> member 4, fee 6
function call: 4   new process: 4   store call: 8   member:4 access: 6
```

## The Cost of Not Holding State

The last line carries three numbers.

**New process: 4.** In the previous lesson a single loan request started no new process; five
processes were already up and serving the request. Here every request spawns four processes,
and all four end. This is the direct consequence of the deployment unit existing only for the
duration of the call.

**Store call: 8.** In the microservice arrangement the member record lived in the membership
process's memory; reaching the record was an in-process object read. Here no function can
carry the record forward, so each function has to pull it from the store on its own call.

**`member:4` access: 6.** The same member record crossed the network six times inside a
single loan workflow: eligibility read it, mark read and wrote it, fee read and wrote it,
notify read it. In the previous lesson the calls made to the membership service were three,
and all three worked on the same object in memory. The difference is the function model's
most concrete cost: **anything that cannot be held in memory crosses the network.**

There is a second consequence tied to this. In the previous lesson `fee-service` kept the
daily rate in its own memory and did the calculation in-process; here the calculation is
still done in-process, but writing the result requires pulling the member record and writing
it back. The last local steps left inside a process turned into outside calls too.

## Time Limit

The second constraint is the per-call time limit. A job that produces notifications for three
overdue books takes 300 ms per record; the limit is 400 ms.

```js
// function-overdue.mjs — processes overdue notifications sequentially in a single call
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const records = await (await fetch(`http://127.0.0.1:${S}/get?a=overdue`)).json();
const done = [];
for (const r of records) {
  await new Promise((c) => setTimeout(c, 300));
  done.push(r);
  await fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: JSON.stringify(done) });
}
console.log(JSON.stringify({ processed: done.length }));
```

```js
// function-overdue-single.mjs — processes one record; state is carried between calls in the store
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const records = await (await fetch(`http://127.0.0.1:${S}/get?a=overdue`)).json();
await new Promise((c) => setTimeout(c, 300));
const done = await (await fetch(`http://127.0.0.1:${S}/get?a=processed`)).json();
done.push(records[Number(process.argv[2])]);
await fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: JSON.stringify(done) });
console.log(JSON.stringify({ processed: done.length }));
```

```js
// duration.mjs — the same work runs first in a single call, then split across calls
import { call, TIMEOUT_MS } from "./runner.mjs";
const S = process.env.STORE;
if (!S) { console.log("STORE environment variable required"); process.exit(0); }
const get = (path) => fetch(`http://127.0.0.1:${S}${path}`);
const reset = () => fetch(`http://127.0.0.1:${S}/put?a=processed`, { method: "POST", body: "[]" });
const track = async () => (await (await get("/counter?reset=1")).json()).calls;
const processed = async () => (await (await get("/get?a=processed")).json()).length;

await reset(); await track();
const single = call("function-overdue.mjs");
console.log(`single run: cut=${single.cut}  store call=${await track()}  processed=${await processed()}/3  limit=${TIMEOUT_MS} ms`);

await reset(); await track();
let cut = 0;
for (const i of ["0", "1", "2"]) if (call("function-overdue-single.mjs", i).cut) cut += 1;
console.log(`split run : call=3  cut=${cut}  store call=${await track()}  processed=${await processed()}/3`);
```

```sh
STORE=8796 node duration.mjs
```

```
single run: cut=true  store call=2  processed=1/3  limit=400 ms
split run : call=3  cut=0  store call=9  processed=3/3
```

In the single run the job was left half-finished: one of three records was processed and the
process was cut off mid-way. The cut is not an error, it is the runner's rule — the code has
no fault, the work is incomplete. What stands out here is that the cut call **left a partial
effect**: one record had already been written to the store.

In the split version the same job spread across three calls and completed. The cost shows up
in the store call count: it went from 2 to 9. The reason is that the work's state can no
longer be carried between calls — every call has to reread both the input list and what has
been processed so far. When the time limit split the workflow, carrying state paid the cost
of the split.

These numbers are relatively resilient to the environment for two reasons: the work per record
was chosen at 300 ms, the limit at 400 ms; the quantities independent of the run are the cut
call count, the processed record count, and the store call count.

```sh
# stop.sh — stops the store process
kill "$(cat store.pid)" 2>/dev/null
echo "stopped"
```

```
stopped
```

## Three Columns

| | Serverless |
|---|---|
| Cheapens | The deployment unit shrank to a single file; the standing process count dropped from 5 to 1, a function does not exist outside of a call |
| Makes expensive | 4 new processes per request; 6 network accesses to the same member record; store calls went from 2 to 9 once the work was split |
| Failure mode created | The time limit cuts work off mid-way: the code has no fault, the result is incomplete, and a partial effect is left in the store |

## Summary

- In the function model the deployment unit exists only for the duration of the call: a loan
  workflow produced 4 function calls and 4 new processes; in the previous lesson this number
  was zero.
- Because nothing stayed in memory between two calls, state moved to the store: the workflow
  made 8 store calls and reached a single member record over the network 6 times.
- The last local steps left inside a process turned into outside calls too; even though the
  calculation still runs in-process, writing the result requires a read and a write.
- The per-call time limit cut the work off mid-way: one of three records was processed, and
  the cut call left a partial effect behind.
- Splitting the work into calls kept it under the limit, but it created a state-carrying cost:
  store calls went from 2 to 9.

## Next Step

At this point the same loan workflow has been built in five forms, and each one has written
its own three columns. One question remains: given a working monolith in hand, **how** does a
move to one of these forms happen. The next lesson takes on the migration itself: the gradual
decomposition of the path, the strangler fig pattern letting the old and new paths run
together for a while, and the cost of that period on the code side — shared logic held in two
codebases, double maintenance cost, and reversibility.
