๐Ÿ—๏ธ Case Studies: Putting It Together

Three real builds, start to finish. Each one: find the bottleneck, pick the model that fits it, then stack the collection's techniques until it holds under load.

How to Read These

Every earlier page taught one move in isolation. Real systems need a combination, and the combination is dictated by the bottleneck. Each case study runs the same loop from Optimizations 101: Measure โ†’ find the bottleneck โ†’ pick the model โ†’ apply techniques โ†’ protect it.

The model (threads / async / multiprocessing) is chosen second, not first. The bottleneck picks the model; the model just decides which flavor of each technique you reach for. A bounded queue, a concurrency cap, and pooling exist in all three โ€” the API differs, the idea doesn't.

Case Study 1 โ€” High-Throughput Web Scraper

Requirement: Fetch 1,000,000 URLs across ~40k hosts, extract a small JSON record from each, and write results to disk. Be polite (cap concurrency per host and overall), survive flaky hosts, and finish on a single 4-core box without OOMing.

Load target: sustain ~2,000โ€“4,000 fetches/sec, steady RSS under ~1 GB regardless of how many URLs remain.

Find the Bottleneck

A fetch is ~200ms of waiting (DNS + TCP + TLS + server think time) and ~1ms of CPU (parse a small JSON body). That's ~99.5% I/O wait. The CPU is idle; the constraint is how many requests we can have in flight at once without melting sockets or memory. Classic I/O-bound.

Little's Law sizing: concurrency = throughput ร— latency. For 3,000 req/s at 200ms each we need 3000 ร— 0.2 = 600 requests in flight. 600 OS threads is painful (stacks, context switches); 600 coroutines is trivial.

Which Model & Why โ†’ ASYNCIO

Threads would work but a thread per in-flight request costs ~8MB of stack and real context-switch overhead; scaling to thousands is wasteful. Multiprocessing gives CPU parallelism we don't need โ€” there's no CPU to parallelize. Asyncio holds tens of thousands of waiting sockets on one core for almost nothing. See the model comparison; for pushing async hard see async at scale.

flowchart TB URLS["url_source()
streams 1M URLs (generator)"] --> SEM["asyncio.Semaphore(600)
global concurrency cap"] SEM --> CLIENT["one shared httpx.AsyncClient
pooled TCP+TLS conns"] CLIENT --> RETRY["fetch_one()
timeout + retry/backoff"] RETRY --> RESQ["bounded asyncio.Queue(maxsize=10k)
results"] RESQ --> WRITER["single writer task
streams JSONL to disk"] WRITER --> DISK["results.jsonl"]

Techniques Applied

Runnable Skeleton

import asyncio
import json
import random
from collections import defaultdict
from urllib.parse import urlsplit

import httpx

# --- tunables (size from Little's Law: throughput * latency) ---
GLOBAL_CONCURRENCY = 600     # ~3000 req/s * 0.2s
PER_HOST_CONCURRENCY = 4     # politeness: never flood one host
RESULT_QUEUE_MAX = 10_000    # backpressure bound on unwritten results
REQUEST_TIMEOUT = 10.0
MAX_RETRIES = 3

limits = httpx.Limits(max_connections=GLOBAL_CONCURRENCY,
                      max_keepalive_connections=200)


def url_source(path: str):
    """Stream URLs from disk โ€” never load 1M into a list (memory-bound trap)."""
    with open(path) as fh:
        for line in fh:
            url = line.strip()
            if url:
                yield url


async def fetch_one(client: httpx.AsyncClient, url: str) -> dict | None:
    """One fetch with timeout + retry/backoff. Returns a record or None on give-up."""
    for attempt in range(MAX_RETRIES):
        try:
            r = await client.get(url, timeout=REQUEST_TIMEOUT)
            r.raise_for_status()
            # tiny CPU cost โ€” fine to do inline; if it grew, offload (see async_at_scale)
            return {"url": url, "status": r.status_code, "len": len(r.content)}
        except (httpx.HTTPError, httpx.TimeoutException):
            if attempt == MAX_RETRIES - 1:
                return {"url": url, "error": True}
            # exponential backoff + full jitter โ€” avoid retry stampedes
            await asyncio.sleep((2 ** attempt) * 0.5 * (0.5 + random.random()))
    return None


async def writer(queue: "asyncio.Queue[dict | None]", out_path: str) -> None:
    """Single consumer: the only thing that touches the file. Bounds memory."""
    with open(out_path, "w") as fh:
        while True:
            record = await queue.get()
            if record is None:          # sentinel = done
                queue.task_done()
                return
            fh.write(json.dumps(record) + "\n")
            queue.task_done()


async def scrape(url_path: str, out_path: str) -> None:
    global_sem = asyncio.Semaphore(GLOBAL_CONCURRENCY)
    host_sems: dict[str, asyncio.Semaphore] = defaultdict(
        lambda: asyncio.Semaphore(PER_HOST_CONCURRENCY)
    )
    results: asyncio.Queue = asyncio.Queue(maxsize=RESULT_QUEUE_MAX)

    async with httpx.AsyncClient(limits=limits, follow_redirects=True) as client:
        writer_task = asyncio.create_task(writer(results, out_path))

        async def worker(url: str) -> None:
            host = urlsplit(url).netloc
            # two nested caps: global budget AND per-host politeness
            async with global_sem, host_sems[host]:
                record = await fetch_one(client, url)
            if record is not None:
                await results.put(record)   # blocks here if writer is behind = backpressure

        # Spawn in bounded waves so we never hold 1M Task objects in memory at once.
        pending: set[asyncio.Task] = set()
        for url in url_source(url_path):
            pending.add(asyncio.create_task(worker(url)))
            if len(pending) >= GLOBAL_CONCURRENCY * 2:
                done, pending = await asyncio.wait(
                    pending, return_when=asyncio.FIRST_COMPLETED
                )
        if pending:
            await asyncio.gather(*pending)

        await results.put(None)   # tell writer to stop
        await writer_task


if __name__ == "__main__":
    asyncio.run(scrape("urls.txt", "results.jsonl"))
Why it holds: the bounded result queue and the wave spawning are the two memory guards โ€” at no point do we hold a million tasks or a million records. The two semaphores are the two politeness/safety guards. Everything reuses one pooled client. Nothing here needs a second core.
Scaling past one box: the moment JSON parsing stops being ~1ms (e.g. you start parsing large HTML with BeautifulSoup), the CPU wakes up and this design breaks โ€” parsing on the loop starves every socket. Then you offload parsing to a ProcessPoolExecutor via loop.run_in_executor (see async at scale), and you've quietly turned Case Study 1 into a hybrid of Case Study 3.

Case Study 2 โ€” High-QPS JSON API

Requirement: A public JSON endpoint. Each request reads from Postgres and calls one slow downstream pricing service, then serializes a moderately large response. Target p99 < 150ms at 8,000 QPS, and stay up when the downstream gets slow.

Load target: hold p99 under SLA; when overloaded, shed load cleanly instead of collapsing (fail fast, don't queue to death).

Find the Bottleneck

Profiling one request: ~5ms DB, ~40ms downstream call (I/O wait), ~8ms JSON serialization (CPU, GIL-held), ~2ms framework. So per request it's ~85% I/O-bound, but with a real CPU tax on serialization that the GIL serializes across all concurrent requests. And there's a downstream-bound failure mode: when pricing slows to 2s, naive code lets requests pile up until the queue eats all memory.

Two constraints fight: (1) we want high concurrency to overlap the 40ms downstream wait, but (2) the 8ms of GIL-bound serialization means only one request actually serializes at a time. At 8k QPS ร— 8ms = 64ms of CPU per second of serialization work per core โ€” need multiple worker processes to parallelize the CPU part regardless of thread/async choice.

Which Model & Why โ†’ THREADS (per-process pool) behind multiple processes

The honest tradeoff:

OptionProCon
Async (FastAPI + asyncpg + httpx)Cheapest concurrency for the 40ms wait; thousands of in-flight requestsThe 8ms serialization blocks the loop โ€” one slow encode stalls every coroutine. Must offload CPU, and the whole stack must be non-blocking (no accidental sync DB driver).
Threads (sync framework + ThreadPoolExecutor)Blocking DB/serialization is fine โ€” the GIL releases during I/O, so threads overlap the wait; simplest to reason about with sync librariesThread-per-request memory; GIL still serializes the CPU part (fixed by running several worker processes, e.g. gunicorn).

We pick threads behind N worker processes (e.g. gunicorn -w 4 -k gthread): it uses the team's existing sync DB and client code, the GIL-releasing I/O lets threads overlap the downstream wait, and the 4 processes give the CPU parallelism serialization needs. Each process owns its own sized SQLAlchemy pool. (Async is the better pick if the whole stack is already non-blocking and concurrency is enormous โ€” see async at scale.)

flowchart TB LB["Load balancer
8000 QPS"] --> P["gunicorn: 4 processes
(CPU parallelism for serialization)"] P --> TP["per-process ThreadPool
gthread workers"] TP --> SHED{"work queue full?
(load shedding)"} SHED -->|"yes"| R503["503 Retry-After
fail fast"] SHED -->|"no"| CACHE{"in cache?"} CACHE -->|"hit"| RESP["serialize + respond"] CACHE -->|"miss"| SF["single-flight
(one loader per key)"] SF --> DB[("Postgres
sized pool")] SF --> CB{"circuit breaker"} CB -->|"closed"| DS["pricing service
(timeout 100ms)"] CB -->|"open"| FALL["fast fallback / 503"] DB --> RESP DS --> RESP

Techniques Applied

Runnable Handler Sketch

import threading
import time

import requests
from sqlalchemy import create_engine, text

# --- one sized pool per worker PROCESS (see connection_pooling) ---
engine = create_engine(
    "postgresql+psycopg://app@db/prod",
    pool_size=20, max_overflow=10, pool_pre_ping=True,
)
downstream = requests.Session()   # pooled keep-alive conns to pricing service


# ---------- single-flight TTL cache (beats cache stampede) ----------
class SingleFlightCache:
    def __init__(self, ttl: float):
        self._ttl = ttl
        self._store: dict[str, tuple[float, object]] = {}
        self._locks: dict[str, threading.Lock] = {}
        self._guard = threading.Lock()

    def get(self, key: str, loader):
        now = time.monotonic()
        hit = self._store.get(key)
        if hit and now - hit[0] < self._ttl:
            return hit[1]
        # only ONE thread loads a given key; the rest wait on its lock
        with self._guard:
            lock = self._locks.setdefault(key, threading.Lock())
        with lock:
            hit = self._store.get(key)          # re-check: someone may have filled it
            if hit and time.monotonic() - hit[0] < self._ttl:
                return hit[1]
            value = loader()
            self._store[key] = (time.monotonic(), value)
            return value


cache = SingleFlightCache(ttl=5.0)


# ---------- circuit breaker for the downstream ----------
class CircuitBreaker:
    def __init__(self, fail_max: int, reset_after: float):
        self._fail_max, self._reset_after = fail_max, reset_after
        self._fails = 0
        self._opened_at = 0.0
        self._lock = threading.Lock()

    def allow(self) -> bool:
        with self._lock:
            if self._fails < self._fail_max:
                return True
            if time.monotonic() - self._opened_at > self._reset_after:
                self._fails = 0          # half-open: let one probe through
                return True
            return False                 # OPEN โ€” fail fast, don't tie up a thread

    def record(self, ok: bool) -> None:
        with self._lock:
            if ok:
                self._fails = 0
            else:
                self._fails += 1
                self._opened_at = time.monotonic()


breaker = CircuitBreaker(fail_max=20, reset_after=5.0)


# ---------- the request handler (runs on a pool thread) ----------
class Overloaded(Exception):
    """Signals the framework to return 503 (load shedding)."""


def handle(product_id: str, queue_depth: int, queue_limit: int) -> dict:
    # 1. LOAD SHEDDING: if the work queue is saturated, reject NOW, don't queue.
    if queue_depth >= queue_limit:
        raise Overloaded()

    # 2. CACHE + single-flight: collapse duplicate concurrent misses into one DB read.
    def load_product():
        with engine.connect() as conn:
            row = conn.execute(
                text("SELECT id, name, base FROM products WHERE id = :id"),
                {"id": product_id},
            ).one()
            return dict(row._mapping)

    product = cache.get(product_id, load_product)

    # 3. DOWNSTREAM behind a circuit breaker + hard timeout.
    price = product["base"]
    if breaker.allow():
        try:
            r = downstream.get(f"http://pricing/quote/{product_id}", timeout=0.1)
            r.raise_for_status()
            price = r.json()["price"]
            breaker.record(ok=True)
        except requests.RequestException:
            breaker.record(ok=False)      # fall back to base price, stay up
    # else: breaker OPEN -> skip the call entirely, serve base price fast

    # 4. Serialization is the GIL-bound CPU cost โ€” parallelized by running N processes.
    return {"id": product["id"], "name": product["name"], "price": price}
Why it holds p99: the timeout + breaker mean a sick downstream costs at most 100ms (then 0ms while open) instead of tying threads up for 2s each; single-flight stops a miss-storm from stampeding Postgres; and load shedding is the release valve โ€” a full queue returns 503 in microseconds, which keeps p99 for the requests you do serve inside SLA. The alternative โ€” unbounded queueing โ€” is exactly the congestion collapse from 101.
Sizing the thread pool: per-request I/O wait โ‰ˆ 45ms, CPU โ‰ˆ 10ms. Threads mostly wait, so max_workers โ‰ˆ cores ร— (1 + wait/cpu) gives a healthy pool โ€” but cap it so the queue bound and the DB pool agree. Never let threads > pool_size or workers block on connections (see thread pool tuning).

Case Study 3 โ€” Batch Data Pipeline

Requirement: Transform a 50 GB newline-delimited dataset (โ‰ˆ300M rows): parse each row, enrich it against a 2 GB in-memory lookup table, run a heavy pure-Python scoring function, and write scored rows out. It's a batch job โ€” no latency SLA, just finish fast on a 16-core box without OOMing.

Load target: saturate all 16 cores, keep RSS roughly flat (input never fully in memory), and load the 2 GB lookup once, not once per worker.

Find the Bottleneck

Profile a sample chunk: parsing + scoring is ~95% CPU, all of it pure-Python, all of it GIL-held. Reading the file is trivial by comparison. This is textbook CPU-bound โ€” and because of the GIL, threads and async give zero speedup here: they'd all fight for one core.

The secondary constraint is memory. 50 GB doesn't fit in RAM, and the 2 GB lookup naively gets copied into every one of the 16 workers = 32 GB โ€” instant OOM. Both problems have standard fixes: stream the input in chunks, and put the lookup in shared_memory.

Which Model & Why โ†’ MULTIPROCESSING

Multiprocessing is the only model that gives real CPU parallelism in CPython โ€” each process has its own GIL, so 16 processes genuinely use 16 cores (see CPU-bound scaling and choosing the model). Threads/async are ruled out by the GIL.

flowchart TB FILE[("input.jsonl
50 GB on disk")] --> READER["stream chunks (generator)
N rows at a time โ€” flat RAM"] LOOK["2 GB lookup table"] --> SHM["shared_memory block
mapped read-only into every worker"] READER --> POOL["ProcessPoolExecutor(16)
map with chunksize"] SHM -.read-only.-> POOL POOL --> W1["worker 1
own GIL, own core"] POOL --> W2["worker 2"] POOL --> WN["... worker 16"] W1 --> COMBINE["main process
streams results in order"] W2 --> COMBINE WN --> COMBINE COMBINE --> OUT[("output.jsonl")]

Techniques Applied

Runnable Skeleton

import json
from concurrent.futures import ProcessPoolExecutor
from itertools import islice
from multiprocessing import shared_memory

WORKERS = 16
CHUNK_ROWS = 5_000        # rows per task โ€” big enough to dwarf pickling overhead
LOOKUP_SHM_NAME = "enrich_lookup"

# Each worker attaches to the shared lookup ONCE and reuses it for every chunk.
_lookup: dict | None = None


def build_shared_lookup(lookup: dict) -> shared_memory.SharedMemory:
    """Main process: serialize the 2 GB table into ONE shared block."""
    import pickle
    blob = pickle.dumps(lookup, protocol=5)
    shm = shared_memory.SharedMemory(name=LOOKUP_SHM_NAME, create=True, size=len(blob))
    shm.buf[: len(blob)] = blob
    return shm


def init_worker() -> None:
    """Runs ONCE per worker process โ€” map the shared lookup, don't copy it."""
    import pickle
    global _lookup
    shm = shared_memory.SharedMemory(name=LOOKUP_SHM_NAME)   # attach, no copy
    _lookup = pickle.loads(bytes(shm.buf))                   # deserialize once per proc
    # NOTE: this deserialization is the one unavoidable per-process cost; the raw
    # 2 GB bytes are shared, so we pay RAM for the dict once per core, not the blob.


def score_chunk(rows: list[str]) -> list[str]:
    """Pure-CPU work โ€” parse, enrich against the shared lookup, score. Runs on its own core."""
    assert _lookup is not None
    out: list[str] = []
    for line in rows:
        rec = json.loads(line)
        rec["region"] = _lookup.get(rec["zip"], "unknown")   # enrichment
        rec["score"] = heavy_score(rec)                       # the CPU-bound hotspot
        out.append(json.dumps(rec))
    return out


def heavy_score(rec: dict) -> float:
    # stand-in for the real pure-Python scoring function (the GIL-bound hotspot)
    return sum(ord(c) for c in rec.get("name", "")) / 100.0


def chunked(path: str, size: int):
    """Stream the 50 GB file in fixed-row chunks โ€” RAM stays flat regardless of file size."""
    with open(path) as fh:
        while True:
            block = list(islice(fh, size))
            if not block:
                return
            yield block


def run(in_path: str, out_path: str, lookup: dict) -> None:
    shm = build_shared_lookup(lookup)
    try:
        with ProcessPoolExecutor(WORKERS, initializer=init_worker) as pool, \
                open(out_path, "w") as out:
            # map streams chunks in, yields results IN ORDER as they finish.
            # chunksize=1 here because each `chunk` is already CHUNK_ROWS rows โ€”
            # the batching is done by chunked(), not by the executor.
            for scored_rows in pool.map(score_chunk, chunked(in_path, CHUNK_ROWS),
                                        chunksize=1):
                out.write("\n".join(scored_rows) + "\n")   # stream results out in order
    finally:
        shm.close()
        shm.unlink()      # free the shared block


if __name__ == "__main__":
    big_lookup = {str(z): f"region-{z % 50}" for z in range(2_000_000)}  # stand-in 2GB table
    run("input.jsonl", "output.jsonl", big_lookup)
Why it holds: 16 processes = 16 real cores of throughput (no GIL contention); chunked() means input RAM is bounded to WORKERS ร— CHUNK_ROWS rows, not 300M; shared_memory means the 2 GB lookup's bytes exist once instead of 16ร—; and pool.map streaming results in order lets us write output incrementally instead of buffering it all. Every one of the pipeline's two constraints (CPU, memory) has its own guard.
The chunksize lesson: the killer of naive multiprocessing is per-task overhead โ€” pickling args to a worker and results back. A million one-row tasks would spend more time pickling than scoring. Batching rows into 5k-row chunks makes the transport cost negligible relative to the CPU work. If your rows were tiny and cheap, this whole job might be transport-bound, not CPU-bound โ€” which is why you profile first.

Decision Recap

The same bottleneck-first logic, three different answers:

WorkloadBottleneckModelKey techniques
Web scraper
1M polite fetches
I/O-bound
(~99% wait)
asyncio
(thousands of cheap waits)
shared AsyncClient pool ยท global + per-host Semaphore ยท bounded result Queue ยท retry/backoff ยท streamed I/O
JSON API
8k QPS, p99 SLA
I/O per request +
GIL-bound serialization +
slow downstream
threads ร—
N processes
(or async if stack is non-blocking)
sized DB pool/process ยท single-flight TTL cache ยท downstream timeout + circuit breaker ยท load shedding on full queue
Batch pipeline
50 GB CPU transform
CPU-bound +
memory (50 GB / 2 GB lookup)
multiprocessing
(only real parallelism)
ProcessPoolExecutor + chunksize ยท streamed chunked input ยท shared_memory lookup ยท per-process initializer ยท in-order streamed output
The through-line: you never started from "I like async" or "processes are fast." You started from a profile, named the bottleneck, and let it choose the model. Then you stacked the same small vocabulary of techniques โ€” pool, cap, bound, cache, retry, stream โ€” in whichever model's dialect fit. That's the whole collection in one move.