InterviewKata
Architecture styles · 09 of 16 · Across machines

Serverless

Serverless runs code as functions that a cloud platform starts per event and bills per use. This page runs Pedal's unlock, billing and nightly report as functions on a simulated platform, shows when cold starts happen, and finds the traffic level at which always-on servers become cheaper.

1. In one picture

Taxis versus owning a car. With your own car you pay for it every day, used or not, and you handle the servicing; a taxi appears when you need one and you pay per trip. A taxi costs more per mile, and on a busy night you might wait for one; for a few trips a week it's far cheaper, and you never think about tyres.

Serverless means running code as functions that a cloud platform starts in response to events (an HTTP request, a message on a queue, a file upload, a schedule), scales automatically, and bills per invocation and per millisecond. There are servers, but you don't choose, patch or keep them; the platform also handles scaling down to zero.

How such a platform is built inside (sandboxes, microVMs, keep-alive) is System design D42; this page is about building on one.

2. Pedal built this way

A few hours of Pedal on functions: two unlocks close together, a billing event, an unlock after a quiet spell, and a nightly report.

3. The rules

RuleWhy it existsBroken when…
Functions keep no state between invocationsAny instance can serve any event; the platform can start and stop them freely.A function caches a rider's session in a global variable and trusts it next time.
Every handler is idempotentEvent sources deliver at least once and retry on failure.A retried billing event charges Mia twice.
Protect the things behind functionsFunctions scale to thousands of copies in seconds; databases don't.A traffic spike opens 2,000 database connections.
Small, single-purpose functions wired by eventsEach scales, fails and deploys on its own.One giant function that routes every request internally (a monolith in a function).

4. How it goes wrong

Cold starts on the hot pathRarely-called, latency-sensitive endpoints pay start-up time; use provisioned (pre-warmed) capacity or keep them on servers.
Connection stormsEach function instance opens its own database connections; use a connection pooler or an HTTP-based data API.
Steady high load on functionsPast a certain volume, always-on servers are cheaper; the lab shows where.
Event spaghettiDozens of functions triggering each other through queues with no picture of the flow; use a workflow engine for multi-step processes.

5. The code

Three Pedal functions (unlock on HTTP, billing on a queue message, a report on a schedule) run by a small stand-in for the platform that reuses an instance if it was used in the last ten minutes. State lives in a managed table, never in the functions.

from __future__ import annotations

# Managed services the functions use. Functions themselves keep nothing between invocations.
TABLE: dict[str, dict] = {}                     # a managed key-value table
DOCKS = {"D12": ["B7", "B9", "B3"]}


def unlock_handler(event: dict) -> str:        # triggered by HTTP
    bike = DOCKS[event["dock"]].pop(0)
    ride_id = f"R{len(TABLE) + 1}"
    TABLE[ride_id] = {"rider": event["rider"], "plan": event["plan"], "bike": bike}
    return f"{ride_id} on {bike}"


def ride_ended_handler(event: dict) -> str:    # triggered by a queue message
    ride = TABLE[event["ride"]]
    m = event["minutes"]
    pence = max(0, m - 30) * 10 if ride["plan"] == "member" else 100 + m * 10
    return f"charged {ride['rider']} £{pence // 100}.{pence % 100:02d}"


def nightly_report_handler(event: dict) -> str:  # triggered by a schedule
    return f"{len(TABLE)} rides today"


class Platform:
    """Stands in for the provider: runs a handler per event, reusing a warm instance if one was used recently."""
    KEEP_WARM_S = 600

    def __init__(self) -> None:
        self.last_used: dict[str, int] = {}
        self.cold = self.warm = 0

    def invoke(self, t: int, trigger: str, handler, event: dict) -> None:
        name = handler.__name__
        is_cold = name not in self.last_used or t - self.last_used[name] > self.KEEP_WARM_S
        self.last_used[name] = t
        if is_cold:
            self.cold += 1
        else:
            self.warm += 1
        start = "cold start (+300 ms)" if is_cold else "warm"
        print(f"t={t}s {trigger} -> {start}: {handler(event)}")


if __name__ == "__main__":
    p = Platform()
    p.invoke(0, "HTTP POST /unlock", unlock_handler, {"rider": "Mia", "plan": "member", "dock": "D12"})
    p.invoke(20, "HTTP POST /unlock", unlock_handler, {"rider": "Leo", "plan": "payg", "dock": "D12"})
    p.invoke(45, "queue ride-ended", ride_ended_handler, {"ride": "R1", "minutes": 42})
    p.invoke(900, "HTTP POST /unlock", unlock_handler, {"rider": "Ana", "plan": "payg", "dock": "D12"})
    p.invoke(10800, "schedule 03:00", nightly_report_handler, {})
    print(f"invocations: {p.cold + p.warm}, cold starts: {p.cold}, servers you manage: 0")
// Managed services the functions use. Functions themselves keep nothing between invocations.
interface Ride { rider: string; plan: string; bike: string; }
const TABLE = new Map<string, Ride>(); // a managed key-value table
const DOCKS: Record<string, string[]> = { D12: ["B7", "B9", "B3"] };
type Ev = { rider?: string; plan?: string; dock?: string; ride?: string; minutes?: number };

function unlockHandler(e: Ev): string { // triggered by HTTP
  const bike = DOCKS[e.dock ?? ""].shift() as string;
  const id = `R${TABLE.size + 1}`;
  TABLE.set(id, { rider: e.rider ?? "", plan: e.plan ?? "", bike });
  return `${id} on ${bike}`;
}
function rideEndedHandler(e: Ev): string { // triggered by a queue message
  const ride = TABLE.get(e.ride ?? "") as Ride, m = e.minutes ?? 0;
  const pence = ride.plan === "member" ? Math.max(0, m - 30) * 10 : 100 + m * 10;
  return `charged ${ride.rider} £${Math.floor(pence / 100)}.${String(pence % 100).padStart(2, "0")}`;
}
function nightlyReportHandler(): string { return `${TABLE.size} rides today`; } // triggered by a schedule

// Stands in for the provider: runs a handler per event, reusing a warm instance if one was used recently.
class Platform {
  static KEEP_WARM_S = 600;
  private lastUsed = new Map<string, number>();
  cold = 0; warm = 0;
  invoke(t: number, trigger: string, name: string, handler: (e: Ev) => string, event: Ev): void {
    const last = this.lastUsed.get(name);
    const isCold = last === undefined || t - last > Platform.KEEP_WARM_S;
    this.lastUsed.set(name, t);
    if (isCold) this.cold++; else this.warm++;
    console.log(`t=${t}s ${trigger} -> ${isCold ? "cold start (+300 ms)" : "warm"}: ${handler(event)}`);
  }
}

const p = new Platform();
p.invoke(0, "HTTP POST /unlock", "unlock", unlockHandler, { rider: "Mia", plan: "member", dock: "D12" });
p.invoke(20, "HTTP POST /unlock", "unlock", unlockHandler, { rider: "Leo", plan: "payg", dock: "D12" });
p.invoke(45, "queue ride-ended", "rideEnded", rideEndedHandler, { ride: "R1", minutes: 42 });
p.invoke(900, "HTTP POST /unlock", "unlock", unlockHandler, { rider: "Ana", plan: "payg", dock: "D12" });
p.invoke(10800, "schedule 03:00", "nightlyReport", nightlyReportHandler, {});
console.log(`invocations: ${p.cold + p.warm}, cold starts: ${p.cold}, servers you manage: 0`);
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

public class Main {
    // Managed services the functions use. Functions themselves keep nothing between invocations.
    static final Map<String, String[]> TABLE = new LinkedHashMap<>(); // a managed key-value table: rider, plan, bike
    static final Map<String, List<String>> DOCKS = new HashMap<>(Map.of("D12", new ArrayList<>(List.of("B7", "B9", "B3"))));

    static String unlockHandler(Map<String, String> e) { // triggered by HTTP
        String bike = DOCKS.get(e.get("dock")).remove(0);
        String id = "R" + (TABLE.size() + 1);
        TABLE.put(id, new String[]{e.get("rider"), e.get("plan"), bike});
        return id + " on " + bike;
    }
    static String rideEndedHandler(Map<String, String> e) { // triggered by a queue message
        String[] ride = TABLE.get(e.get("ride"));
        int m = Integer.parseInt(e.get("minutes"));
        int pence = ride[1].equals("member") ? Math.max(0, m - 30) * 10 : 100 + m * 10;
        return "charged " + ride[0] + " £" + (pence / 100) + "." + String.format("%02d", pence % 100);
    }
    static String nightlyReportHandler(Map<String, String> e) { return TABLE.size() + " rides today"; } // triggered by a schedule

    // Stands in for the provider: runs a handler per event, reusing a warm instance if one was used recently.
    static class Platform {
        static final int KEEP_WARM_S = 600;
        final Map<String, Integer> lastUsed = new HashMap<>();
        int cold = 0, warm = 0;
        void invoke(int t, String trigger, String name, Function<Map<String, String>, String> handler, Map<String, String> event) {
            Integer last = lastUsed.get(name);
            boolean isCold = last == null || t - last > KEEP_WARM_S;
            lastUsed.put(name, t);
            if (isCold) cold++; else warm++;
            System.out.println("t=" + t + "s " + trigger + " -> " + (isCold ? "cold start (+300 ms)" : "warm") + ": " + handler.apply(event));
        }
    }

    public static void main(String[] args) {
        Platform p = new Platform();
        p.invoke(0, "HTTP POST /unlock", "unlock", Main::unlockHandler, Map.of("rider", "Mia", "plan", "member", "dock", "D12"));
        p.invoke(20, "HTTP POST /unlock", "unlock", Main::unlockHandler, Map.of("rider", "Leo", "plan", "payg", "dock", "D12"));
        p.invoke(45, "queue ride-ended", "rideEnded", Main::rideEndedHandler, Map.of("ride", "R1", "minutes", "42"));
        p.invoke(900, "HTTP POST /unlock", "unlock", Main::unlockHandler, Map.of("rider", "Ana", "plan", "payg", "dock", "D12"));
        p.invoke(10800, "schedule 03:00", "nightlyReport", Main::nightlyReportHandler, Map.of());
        System.out.println("invocations: " + (p.cold + p.warm) + ", cold starts: " + p.cold + ", servers you manage: 0");
    }
}

Output (the same in all three languages)

t=0s HTTP POST /unlock -> cold start (+300 ms): R1 on B7
t=20s HTTP POST /unlock -> warm: R2 on B9
t=45s queue ride-ended -> cold start (+300 ms): charged Mia £1.20
t=900s HTTP POST /unlock -> cold start (+300 ms): R3 on B3
t=10800s schedule 03:00 -> cold start (+300 ms): 3 rides today
invocations: 5, cold starts: 4, servers you manage: 0

6. Try it

Functions or always-on servers?

Pick Pedal's monthly traffic and how heavy each request is, and compare function pricing with two always-on servers (two, so one can fail).

Requests a month
Run time per request
Memory per function

AWS list prices in US East, checked October 2026: Lambda $0.20 per million requests plus $0.0000166667 per GB-second (x86); an m7g.large server $0.0816 an hour, about $60 a month, two for redundancy. Free tiers, data transfer and the work of running servers are left out; the servers are assumed big enough for the traffic.

7. What it costs, and when to pick another style

What you get

  • No servers to run: patching, capacity and scaling are the provider's job.
  • Pay per use: a quiet service costs almost nothing, including at night.
  • Automatic scaling: from zero to thousands of instances for a spike.
  • Fast to build glue: events from storage, queues and schedules wire up quickly.

What you pay

  • Cold starts: occasional extra latency.
  • Limits: run time, memory, payload and concurrency caps.
  • Cost at steady volume: per-request pricing overtakes servers at high, constant load.
  • Lock-in and local testing: triggers and permissions are provider-specific and hard to reproduce on a laptop.
If…Consider instead
Steady, high traffic or long-running workContainers or servers: tiers or microservices
Strict latency on every requestAlways-warm services, or provisioned capacity
Multi-step processes with retries and waitsA workflow engine with event-driven steps
Many teams sharing one domain modelService-based

8. Signs you've outgrown it

9. Remember it and say it

Memory hook: "A taxi, not a car"Code runs per event on a platform you don't operate, billed per use; great for spiky or small work, pricey at steady volume.
How this hook works: what “A taxi, not a car” means

What the slogan means, phrase by phrase

“A taxi”
Functions started on demand, billed per trip.
“not a car”
No server to own, maintain or pay for while parked.

Why this picture fits: Taxis vs owning a car: no servicing and nothing while idle, but more per mile and a wait on a busy night.

The story → the concept

TaxisPedal
Hailing a taxiAn event triggers a function
Waiting for one to arriveA cold start
A taxi that's already nearbyA warm instance
Paying per tripPer-request and per-millisecond billing

Watch it on an example

  1. Two unlocks 20 seconds apart: the second is warm.
  2. An unlock after 15 quiet minutes waits for a cold start.
  3. The nightly report costs a few milliseconds a day.

The trap it saves you from

Putting a steady, heavy workload on functions and paying per request for something a server would do cheaper.

It still works when…

Mixed in: many systems run their steady core on servers and their spiky edges (uploads, webhooks, nightly jobs) on functions.

In an interview or design review

10. Check yourself

A function stores a rider's session in a global variable. What can go wrong?

Functions are stateless; state goes to a managed store.

A queue retries a billing event after a timeout. What must the handler be?

Event sources deliver at least once.

Pedal's unlock path is called rarely at night and must answer quickly. What helps most?

It removes cold starts on the critical path.

When do always-on servers beat functions on cost?

Per-request pricing overtakes a fixed monthly cost.

11. Go deeper

ResourceWhy
Architecture 16a: Pipeline and serverlessHow an architect weighs the style.
System design D42: Serverless platformHow the platform itself works.
AWS Lambda quotasThe real limits: run time, memory, payloads, concurrency.