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
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.
- Functions are stateless: each invocation may run in a fresh instance, so anything that must survive goes to a managed store, queue or object storage.
- Cold starts: when no warm instance exists, the platform starts one first, adding from tens of milliseconds to seconds.
- Limits: maximum run time (AWS Lambda allows up to 15 minutes), memory, payload size and concurrency per account.
- It usually comes with managed services: API gateways, queues, managed databases and workflow engines, so the "architecture" is how events flow between them.
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
| Rule | Why it exists | Broken when… |
|---|---|---|
| Functions keep no state between invocations | Any 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 idempotent | Event sources deliver at least once and retry on failure. | A retried billing event charges Mia twice. |
| Protect the things behind functions | Functions scale to thousands of copies in seconds; databases don't. | A traffic spike opens 2,000 database connections. |
| Small, single-purpose functions wired by events | Each 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
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).
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 work | Containers or servers: tiers or microservices |
| Strict latency on every request | Always-warm services, or provisioned capacity |
| Multi-step processes with retries and waits | A workflow engine with event-driven steps |
| Many teams sharing one domain model | Service-based |
8. Signs you've outgrown it
- The monthly function bill is higher than the servers that would carry the same load.
- Work regularly hits the run-time or memory limits.
- Cold starts are a recurring complaint, and pre-warming costs as much as servers.
- Nobody can draw how events flow between the functions.
9. Remember it and say it
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
| Taxis | Pedal |
|---|---|
| Hailing a taxi | An event triggers a function |
| Waiting for one to arrive | A cold start |
| A taxi that's already nearby | A warm instance |
| Paying per trip | Per-request and per-millisecond billing |
Watch it on an example
- Two unlocks 20 seconds apart: the second is warm.
- An unlock after 15 quiet minutes waits for a cold start.
- 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
- Say what triggers what: "HTTP to unlock, a queue to billing, a schedule to the nightly report; state in a managed table."
- Address cold starts and connections up front: provisioned capacity for the unlock path, a pooler or data API for the database.
- Do the cost crossover out loud with the traffic numbers; it's often the deciding argument.
10. Check yourself
A function stores a rider's session in a global variable. What can go wrong?
A queue retries a billing event after a timeout. What must the handler be?
Pedal's unlock path is called rarely at night and must answer quickly. What helps most?
When do always-on servers beat functions on cost?
11. Go deeper
| Resource | Why |
|---|---|
| Architecture 16a: Pipeline and serverless | How an architect weighs the style. |
| System design D42: Serverless platform | How the platform itself works. |
| AWS Lambda quotas | The real limits: run time, memory, payloads, concurrency. |