1. Courses
  2. AI Engineering
  3. AI Systems for Builders
  4. Stop 2

40 minAdvanced

ChatGPT: serving a large language model to hundreds of millions

In October 2025, OpenAI's CEO said more than 800 million people use ChatGPT every week, and its API processes over 6 billion tokens a minute. Most of ChatGPT's internal design is private, but OpenAI has published real pieces: its PostgreSQL setup, its Kubernetes clusters, its outages, and how its engineers think about GPUs. This lesson puts those pieces together with how LLM serving works in general, and then you build a streaming chat proxy you can put in front of any model.

By the end you can

  • Trace a message from the send button to a GPU and back as a stream of tokens
  • Explain prefill, decode, the KV cache and continuous batching in plain words
  • Explain why GPU capacity cannot autoscale, and what rate limits and queues do about it
  • Read OpenAI's published PostgreSQL design like a DBA, and know which parts to copy
  • Build a NestJS proxy with per-user limits, a bounded queue, SSE streaming and saved conversations

The everyday picture

Think of a busy flatbread oven at a kebab house on a Friday night, the kind you find from Dhaka to Istanbul to Lagos. The oven is the expensive part: there is one, it has room for a fixed number of flatbreads, and you cannot buy a second oven in the next ten minutes. When an order arrives, the cook first reads the whole order and prepares all the dough at once. That part is quick. Then the flatbreads come out one at a time, each one only after the one before. The waiter carries each one to the table as soon as it is ready, so the table starts eating early.

A clumsy cook fills the oven for four tables and waits until the slowest table's order is finished before taking new ones. A good cook puts a new flatbread into any gap the moment one comes out. Each table also needs space on the counter for its half-finished order, and the counter is small. When the counter is full, new customers wait at the door, or the manager says "we are full tonight". Serving ChatGPT is this kitchen: the oven is the GPU (the graphics chip that runs the model), the flatbreads are tokens, the counter is GPU memory, and the manager at the door is the rate limiter and queue.

What the product must do

  • Functional. Sign in, type or speak a message, get an answer that appears word by word, keep the conversation history, attach files and images, and choose between models. Paid plans get more use than free ones. Developers reach the same models through an API with keys and usage limits.
  • Non-functional. The first words should appear within a second or two. The answer must keep flowing without gaps. Capacity must be shared fairly between free users, paying users and API customers. Conversations must never leak between users. And because every answer runs on very expensive hardware, the cost of each token matters.

The unusual requirement is the last one. In most web systems, a request costs a few milliseconds of CPU. Here, one answer can keep a slice of a GPU busy for many seconds, and there is a fixed number of GPUs.

Scale in numbers

MetricNumberSource, year
People using ChatGPT each weekmore than 800 millionSam Altman at OpenAI DevDay, reported by TechCrunch, 2025
Tokens processed by the APImore than 6 billion a minutesame, 2025
New sign-ups during the image generation launchover 100 million in one weekOpenAI engineering, 2026
PostgreSQLone primary, nearly 50 read replicas, millions of queries a secondOpenAI engineering, 2026
Growth in PostgreSQL loadmore than 10× in one yearOpenAI engineering, 2026
Largest Kubernetes clusters (research)7,500 nodesOpenAI, 2021
GPU used as an example (NVIDIA H100)3.35 TB/s memory speed, 1.98 quadrillion operations a secondOpenAI's Evan Morikawa, The Pragmatic Engineer, 2024

Two things stand out. The user numbers are among the largest of any software product. And the database at the centre is one PostgreSQL primary, the same kind of server you can run yourself.

Press send: the request path

Here is a message's trip, with each step marked as published or general.

  1. The edge checks who you are and how much you may use. Published for the API: limits are counted in requests and tokens per minute and per day, they rise with a customer's usage tier, and going over returns HTTP 429 with a Retry-After header. ChatGPT plans have their own limits, which OpenAI tightens at busy times; during the March 2025 image launch, Altman wrote that "our GPUs are melting" and free users were held to 3 images a day. This is the token bucket from Rate limiting, keyed by user and plan; you will build one in the Practice section.
  2. A service loads the conversation. General: the model has no memory between requests, so every turn sends the earlier messages again. Someone must fetch them, quickly. Published: OpenAI uses Redis to cache user information (its March 2023 incident report), and its main database is PostgreSQL. OpenAI has not said where conversations themselves live.
  3. Safety checks run. Published: OpenAI offers developers a free moderation model that scores text and images in 13 harm categories, and warns that streaming makes moderation harder, because a half-written answer is hard to judge. Not published: exactly how ChatGPT's own checks are placed.
  4. A router picks a model server. General: a scheduler places the request on a group of GPU servers running the chosen model, choosing by load. If all are full, the request waits in a queue or is refused. Morikawa said in 2024 that the "we are at capacity" message from ChatGPT's early days was literally true.
  5. The GPU works in two phases. First it reads the whole prompt at once (prefill), then it writes the answer one token at a time (decode). The next two sections explain why this shapes everything.
  6. Tokens stream back. Published for the API: with stream: true, answers arrive as , one small chunk after another, ending with data: [DONE] (Real-time updates). The first words appear while the rest is still being generated.
  7. The answer is saved. General: the finished, or partly finished, answer is written back to the conversation, so it is there on your next device.
Chat app 1. message API edge auth, plan limit 429 retry later Conversation load history Postgres + cache read, save Safety check Router + queue pick a GPU pool all full wait or refuse GPU server prefill once, then decode per token tokens stream back over SSE
A message's trip, as a general model. Two places can stop it early: the plan limit at the edge (429) and a full GPU pool at the router (wait in a queue, or refuse). On the GPU, the prompt is read once, then tokens are generated one at a time and stream straight back to the app. OpenAI has published the limits, the streaming format, its PostgreSQL design and its Redis cache, but not this exact service map.

Inside the GPU: prefill, decode and the KV cache

This section is general knowledge about how transformer models are served, with OpenAI's published comments where they exist.

A model writes one at a time. To write token number 501, it needs to look back at all 500 before it. Recomputing everything about those 500 for every new token would be hopeless, so the model keeps, for every earlier token and every layer, two lists of numbers called keys and values. This store is the . It lives in GPU memory and grows with every token.

That splits each request into two phases with very different costs, as NVIDIA's 2023 guide explains:

  • reads the whole prompt in one go. All tokens are processed in parallel, so the GPU's maths units are fully busy. A long prompt makes the first token slower, which is why grows with prompt length.
  • produces one token per step. Each step must read all the model's weights from memory, but does only a little maths per request. The GPU spends most of its time waiting for memory.

Morikawa put numbers on this in 2024. An H100 GPU can move about 3.35 terabytes a second from its memory, and do about 1.98 quadrillion operations a second. That is roughly 591 operations for every byte it reads. One user's decode step uses each byte of weights for only a couple of operations, so the GPU sits mostly idle. The fix is to batch: run the decode step for many users at once, so each read of the weights serves all of them. Throughput rises almost for free, until something else runs out.

What runs out is memory. Every user in the batch needs their own KV cache. NVIDIA's guide works one example: a 7-billion-parameter model in 16-bit numbers, with 4,096 tokens of text, needs about 2 GB of KV cache for a single request. Larger models and longer chats need much more. So the number of users one GPU can serve at once is set by how much memory is left for KV caches. Morikawa said that GPU memory, not computing power, was ChatGPT's most frequent bottleneck, and that the team learned to watch KV cache use and batch size as the real load signals, because plain "GPU utilisation" was misleading. He also described what happens when a conversation's cache is pushed out of memory: the next turn must redo the prefill for the whole conversation, which costs far more than a normal cache miss.

Quick check

A user pastes a 20-page report and asks one short question. Which part of the wait grows the most?

Batching: keeping the oven full

The simple way to batch is static batching: collect a few requests, run them together until all are finished, then take the next group. Answers have very different lengths, so short answers finish early and their slots sit empty while the longest one finishes, and new requests wait at the door.

The Orca paper (OSDI 2022) proposed scheduling at the level of each step instead of each request. After every decode step, finished requests leave and waiting ones join. This is now called . The authors reported up to 36.9 times more throughput than an earlier serving system on a GPT-3-sized model at the same latency. A year later, vLLM (SOSP 2023) attacked the memory side: it stores KV caches in fixed-size pages, like an operating system's virtual memory, so little memory is wasted and more requests fit in a batch. It reported 2 to 4 times more throughput than the systems before it. The same paged design also lets requests that start with the same text share those pages, which is the idea behind prompt caching in the next lesson, Claude.

Quick check

Your team's self-hosted model server shows GPU utilisation at 40%, yet new requests are waiting. What should you look at first?

Static batching slot 1 slot 2 slot 3 slot 4 A E B C D E waits for slowest B E done: step 12 Continuous batching slot 1 slot 2 slot 3 slot 4 A F B C D E E takes D's free slot E done: step 6 time, one step per token prefill decode idle slot
Four GPU slots over twelve model steps (simplified: a long prompt's prefill can take longer than one step). With static batching, slots sit idle until the longest answer, B, finishes, and E starts only at step 10. With continuous batching, E joins the moment D leaves and F joins when A leaves. Same GPU, more answers, and E's user waits much less.
Think first: why can't OpenAI just autoscale when a launch goes viral, the way a web API adds servers?

Autoscaling (Scaling up vs scaling out) assumes there are spare machines to rent. For large GPUs there often are not: Morikawa said in 2024 that there were no more GPUs to add, and that OpenAI spread across many Azure regions to find capacity, putting nearness to users second. Even when GPUs exist, a server must load a model of many gigabytes before it can serve. So the levers during a spike are the ones in front of the GPUs: tighter limits per plan, queues, shorter answers, and, during the 2025 image launch, temporary daily caps for free users. That is load shedding from Rate limiting, decided by product as much as by engineering.

The data layer, a DBA's view

OpenAI's January 2026 post on scaling PostgreSQL is one of the most useful database documents any large AI company has published, because it describes a setup you could build yourself. Here is what it says, with links to the Kolom lessons that explain each idea.

  • One writer, many readers. A single primary on Azure Database for PostgreSQL flexible server, with nearly 50 in several regions, serving millions of queries a second with p99 latency in the low tens of milliseconds (Replication). The primary runs with a hot standby ready to take over. OpenAI reports five nines of availability for this system, and one top-severity incident in the past year.
  • Writes are the limit. The post says plainly that a single primary cannot scale writes. PostgreSQL's copies a whole row on every update, so heavy writes cause bloat and make autovacuum tuning hard (Postgres ops). So write-heavy workloads that can be split by key were moved to sharded systems such as Azure Cosmos DB, new workloads go to the sharded systems by default, and no new tables may be added to the PostgreSQL cluster.
  • Connections through a pooler. Each Azure instance allows up to 5,000 connections. With in front, the average time to get a connection fell from 50 ms to 5 ms (Postgres ops).
  • A cache that protects the database. When cache hit rates drop, a burst of misses lands on PostgreSQL. OpenAI added a lock so that only one reader per missing key fetches from the database, which is the stampede lock from Caching, at 800-million-user scale.
  • Noisy neighbours kept apart. Requests are split into high and low priority and sent to separate instances, and rate limits exist at several layers: application, pooler, proxy and query (Rate limiting).
  • Strict migration rules. Schema changes have a 5-second timeout, only light changes are allowed, and nothing may rewrite a whole table (Safe migrations). Complex joins are avoided; when needed, the join logic moves into the application.

The one top-severity incident was the image generation launch, when over 100 million new users signed up in a week and write traffic jumped more than 10 times. Next on OpenAI's list are cascading replication (replicas feeding other replicas, to go beyond about 50) and possibly sharding PostgreSQL itself. The lesson for a smaller team is encouraging: a boring PostgreSQL primary with replicas, a pooler, a cache and firm rules can carry a very long way. You change the plan when writes, not reads, hit the ceiling.

Quick check

Adding another read replica to OpenAI's cluster would NOT help with which problem?

Servers and infrastructure

OpenAI's 2021 post on Kubernetes describes its research clusters, which train models, not the ChatGPT serving fleet. Even so, it shows the style. The largest clusters had 7,500 nodes, usually with one pod taking a whole GPU machine, about 200,000 IP addresses in use, and 5 API servers and 5 etcd nodes at the core. Each API server could use up to 70 GB of memory. Because GPUs fail in specific ways, every node runs GPU tests when it boots and again on a schedule. Morikawa's 2024 interview adds that ChatGPT's GPUs are spread across many Azure regions, chosen by where capacity existed.

For our purposes, the important idea is the same one Netflix uses (Netflix): a control plane (Kubernetes, schedulers, routers) decides where work runs, and a data plane (the GPU servers) does the work. The December 2024 outage below shows what happens when the two are tied too closely.

Three decisions and what they taught

  1. Stream every answer. An answer that takes 10 seconds to write feels quick when the first words appear in under a second. Streaming changes how a slow dependency feels, and it lets the user stop early, which saves GPU time (Real-time updates).
  2. Keep the core database boring, then move writes out. One PostgreSQL primary, replicas, a pooler, a cache with a stampede lock, and strict rules, instead of an early rewrite to an exotic store. The write-heavy parts moved to sharded systems only when needed (Sharding, Beyond SQL).
  3. Treat capacity as a product decision. When GPUs are the limit, limits per plan, queues and daily caps are product features with a cost to users, and they need a product owner as much as error handling does. Someone in product has to decide who waits.

When it broke: a telemetry service and a stuck control plane

On 11 December 2024, ChatGPT, the API and other services were down or degraded for 4 hours and 22 minutes. OpenAI's write-up describes the chain. A new telemetry service was rolled out to its Kubernetes clusters. On every node, it made API calls whose cost grew with the size of the cluster. With thousands of nodes doing this at once, the Kubernetes API servers were overwhelmed. Services find each other through DNS inside the cluster, and that DNS depended on the API servers, so service discovery broke.

Two details made it worse. First, DNS caching hid the problem at the start: services kept using old but working addresses for a while, so the rollout continued to more clusters before the damage showed. Second, to undo the change, engineers needed the Kubernetes API, which was the very thing that was overloaded. OpenAI's fixes: slower, phased rollouts watched by health checks; tests that deliberately break the control plane; "break-glass" access to the API servers in any situation; and making the data plane work without depending on the control plane.

An older incident teaches a different lesson. On 20 March 2023, a bug in the redis-py client library meant that when a request was cancelled at the wrong moment, the next request on the same connection could receive the previous request's data. For about nine hours, some users saw other users' chat titles, and about 1.2% of ChatGPT Plus subscribers active in that window may have had payment details shown to another user, though never full card numbers. Among OpenAI's fixes: extra checks that cached data really belongs to the user asking for it. In your own code, the cheap version of that check is to always include the user ID in the query, as the Build-it does.

Build it

A provider-agnostic streaming chat proxy in NestJS 10: per-user token bucket by plan, a bounded queue when capacity is full, SSE to the client, and every message saved in Postgres. Two of its core pieces, the stream parser in step 2 and the token bucket in step 3, are exercises you can run in your browser in the Practice section.

  1. Create the tables

    Every assistant message has a status, because streams end in three ways: finished, stopped by the user, or broken by the provider. Token counts let you add up cost later.

    SQL
    CREATE TABLE conversation (
      id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
      user_id     integer        NOT NULL,
      title       text,
      created_at  timestamptz(3) NOT NULL DEFAULT now()
    );
    CREATE INDEX conversation_user_id_created_at_idx ON conversation (user_id, created_at);
    
    CREATE TABLE chat_message (
      id               bigserial PRIMARY KEY,
      conversation_id  uuid NOT NULL REFERENCES conversation (id),
      role             text NOT NULL CHECK (role IN ('user', 'assistant')),
      content          text NOT NULL,
      status           text NOT NULL DEFAULT 'complete'
                       CHECK (status IN ('complete', 'stopped', 'failed')),
      input_tokens     integer,
      output_tokens    integer,
      created_at       timestamptz(3) NOT NULL DEFAULT now()
    );
    CREATE INDEX chat_message_conversation_id_id_idx ON chat_message (conversation_id, id);
  2. Hide the provider behind one interface

    Many hosted providers, and self-hosted servers such as vLLM, accept the OpenAI-style Chat Completions format and stream lines like data: {"choices":[{"delta":{"content":"Hel"}}]}, ending with data: [DONE]. This class reads that stream and turns it into simple events. The signal matters: when it fires, fetch closes the connection and the provider stops generating, so you stop paying. For a provider with its own SDK, write a second class with the same interface.

    TypeScript
    // src/llm/provider.ts: the only file that knows which LLM you call
    export interface ChatTurn { role: 'user' | 'assistant'; content: string }
    export interface StreamOpts { system: string; maxOutputTokens: number; signal: AbortSignal }
    export type ChatEvent =
      | { type: 'delta'; text: string }                                // a piece of the answer
      | { type: 'done'; inputTokens: number; outputTokens: number };   // final usage
    export interface ChatProvider { stream(turns: ChatTurn[], o: StreamOpts): AsyncIterable<ChatEvent> }
    export const CHAT_PROVIDER = Symbol('CHAT_PROVIDER');
    
    export class OpenAICompatibleProvider implements ChatProvider {
      constructor(private baseUrl: string, private apiKey: string, private model: string) {}
    
      async *stream(turns: ChatTurn[], o: StreamOpts): AsyncIterable<ChatEvent> {
        const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
          method: 'POST', signal: o.signal,                 // user left or deadline hit: stop paying
          headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },
          body: JSON.stringify({ model: this.model, stream: true, max_tokens: o.maxOutputTokens,
            stream_options: { include_usage: true },        // last chunk carries token counts
            messages: [{ role: 'system', content: o.system }, ...turns] }),
        });
        if (!res.ok || !res.body) throw new Error(`provider HTTP ${res.status}`);
        let buf = '', usage = { inputTokens: 0, outputTokens: 0 };
        for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
          buf += chunk;
          const lines = buf.split('\n'); buf = lines.pop() ?? '';   // keep a half line for later
          for (const line of lines) {
            if (!line.startsWith('data:')) continue;                 // skip blanks and comments
            const data = line.slice(5).trim();
            if (data === '[DONE]') { yield { type: 'done', ...usage }; return; }
            const j = JSON.parse(data);
            if (j.choices?.[0]?.delta?.content) yield { type: 'delta', text: j.choices[0].delta.content };
            if (j.usage) usage = { inputTokens: j.usage.prompt_tokens, outputTokens: j.usage.completion_tokens };
          }
        }
        yield { type: 'done', ...usage };
      }
    }
  3. Limit each user by plan

    Reuse the atomic token bucket Lua script from Rate limiting (save it as token_bucket.lua next to this file and copy it into dist on build). Free users get a burst of 5 and then about 3 messages a minute; paid users a burst of 20 and 30 a minute. The limiter runs before the response starts, so a refused request is a normal HTTP 429.

    TypeScript
    // src/chat/chat-limiter.ts
    import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
    import Redis from 'ioredis';
    import { readFileSync } from 'node:fs';
    import { join } from 'node:path';
    
    const PLANS = { free: { burst: 5, perS: 0.05 }, plus: { burst: 20, perS: 0.5 } }; // 3 vs 30 a minute
    export type Plan = keyof typeof PLANS;
    
    @Injectable()
    export class ChatLimiter {
      private lua = readFileSync(join(__dirname, 'token_bucket.lua'), 'utf8');
      constructor(private redis: Redis) {}
    
      /** Throws 429 with a retry time. Call it before any SSE bytes are sent. */
      async admit(userId: number, plan: Plan): Promise<void> {
        const { burst, perS } = PLANS[plan];
        const [allowed, , retryMs] =
          (await this.redis.eval(this.lua, 1, `chat:${userId}`, burst, perS, 1)) as number[];
        if (allowed !== 1) throw new HttpException(
          { error: 'rate_limited', retryAfterS: Math.ceil(retryMs / 1000) }, HttpStatus.TOO_MANY_REQUESTS);
      }
    }
  4. Add a capacity gate with a short, bounded queue

    The provider, or your own model server, can run only so many answers at once. This gate lets maxActive calls run, keeps up to maxWaiting in line for at most maxWaitMs, and refuses the rest at once. A finished call hands its slot straight to the next in line. If the user leaves while waiting, they leave the line.

    TypeScript
    // src/chat/capacity-gate.ts
    export class GateFullError extends Error {}
    
    export class CapacityGate {
      private active = 0;
      private line: Array<() => void> = [];
    
      constructor(private maxActive: number, private maxWaiting: number, private maxWaitMs: number) {}
    
      /** Resolves with a release function. onQueued tells the caller its place in line. */
      async acquire(signal: AbortSignal, onQueued: (position: number) => void): Promise<() => void> {
        if (this.active < this.maxActive) { this.active++; return this.releaser(); }
        if (this.line.length >= this.maxWaiting) throw new GateFullError();   // shed load now
        onQueued(this.line.length + 1);
        await new Promise<void>((resolve, reject) => {
          const leave = (err: Error) => {
            clearTimeout(timer); this.line = this.line.filter((f) => f !== go); reject(err);
          };
          const go = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(); };
          const onAbort = () => leave(new Error('client left while queued'));
          const timer = setTimeout(() => leave(new GateFullError()), this.maxWaitMs);
          signal.addEventListener('abort', onAbort, { once: true });
          this.line.push(go);
        });
        return this.releaser();                       // the slot was handed straight to us
      }
    
      private releaser(): () => void {
        let done = false;
        return () => {
          if (done) return; done = true;
          const next = this.line.shift();
          if (next) next(); else this.active--;       // hand the slot on, or free it
        };
      }
    }

    This gate counts per API copy: with 4 copies and maxActive 8, up to 32 calls run. For one shared limit across copies, use a Redis-based semaphore, or let your model server queue requests itself.

  5. Check, wait, stream and save

    The service finds the conversation with the user's ID in the query, so a guessed ID returns nothing (the March 2023 lesson). It loads the last 20 complete messages, saves the new one, waits for a slot, streams, and in finally always releases the slot and saves whatever was written, with the right status.

    TypeScript
    // src/chat/chat.service.ts
    import { Inject, Injectable } from '@nestjs/common';
    import { PrismaClient } from '@prisma/client';
    import { CHAT_PROVIDER, ChatProvider, ChatTurn } from '../llm/provider';
    import { CapacityGate, GateFullError } from './capacity-gate';
    
    export type Send = (event: string, data: object) => void;          // writes one SSE event
    const SYSTEM = 'You help Mangrove Market store staff. Answer briefly.';
    
    @Injectable()
    export class ChatService {
      // at most MAX_ACTIVE model calls from this API copy; 50 may wait, each up to 20 s
      private gate = new CapacityGate(Number(process.env.MAX_ACTIVE ?? 8), 50, 20_000);
      constructor(private prisma: PrismaClient, @Inject(CHAT_PROVIDER) private llm: ChatProvider) {}
    
      async reply(userId: number, conversationId: string, text: string, send: Send, signal: AbortSignal) {
        const owned = await this.prisma.conversation.findFirst({ where: { id: conversationId, userId } });
        if (!owned) return send('error', { error: 'not_found' });          // never another user's chat
        const history = await this.prisma.chatMessage.findMany({ where: { conversationId, status: 'complete' },
          orderBy: { id: 'desc' }, take: 20, select: { role: true, content: true } });
        await this.prisma.chatMessage.create({ data: { conversationId, role: 'user', content: text } });
    
        let release: () => void;
        try { release = await this.gate.acquire(signal, (position) => send('queued', { position })); }
        catch (e) { if (e instanceof GateFullError) send('error', { error: 'busy', retryAfterS: 30 }); return; }
    
        let answer = '', status = 'complete', usage = { inputTokens: 0, outputTokens: 0 };
        try {
          send('start', {});
          const turns = [...history.reverse(), { role: 'user', content: text }] as ChatTurn[];
          for await (const ev of this.llm.stream(turns, { system: SYSTEM, maxOutputTokens: 800, signal })) {
            if (ev.type === 'delta') { answer += ev.text; send('delta', { text: ev.text }); }
            else usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens };
          }
          send('done', usage);
        } catch {
          status = signal.aborted ? 'stopped' : 'failed';                 // user left, or provider broke
          if (!signal.aborted) send('error', { error: 'model_failed' });
        } finally {
          release();                                                      // always give the slot back
          await this.prisma.chatMessage.create({ data: { conversationId, role: 'assistant', content: answer,
            status, inputTokens: usage.inputTokens || null, outputTokens: usage.outputTokens || null } });
        }
      }
    }

    The service does not retry a provider failure mid-answer: half an answer is already on the user's screen, and a retry would repeat it. Retry only before the first token, with backoff (Timeouts and retries, and the resilient client in Claude).

  6. Write the SSE endpoint

    The message comes in a POST body, so this uses @Res() and writes the events by hand instead of the GET-only @Sse() from Real-time updates. In a browser, read it with fetch and a stream reader, since EventSource only does GET. When the user closes the tab, close fires, the signal aborts, and the provider call stops.

    TypeScript
    // src/chat/chat.controller.ts
    import { Body, Controller, Headers, Param, ParseUUIDPipe, Post, Res } from '@nestjs/common';
    import type { Response } from 'express';
    import { ChatLimiter, Plan } from './chat-limiter';
    import { ChatService } from './chat.service';
    
    @Controller('conversations')
    export class ChatController {
      constructor(private chat: ChatService, private limiter: ChatLimiter) {}
    
      @Post(':id/messages')
      async send(@Param('id', ParseUUIDPipe) id: string, @Body('text') text: string,
                 @Headers('x-user-id') user: string, @Headers('x-plan') plan: Plan,  // stand-ins for
                 @Res() res: Response) {                                            // your auth guard
        const userId = Number(user);
        await this.limiter.admit(userId, plan === 'plus' ? 'plus' : 'free');        // 429 happens here
        res.set({ 'content-type': 'text/event-stream', 'cache-control': 'no-cache',
                  'x-accel-buffering': 'no' });                                     // Nginx: don't buffer
        res.flushHeaders();
    
        const ac = new AbortController();
        res.on('close', () => { if (!res.writableEnded) ac.abort(); });            // tab closed: stop
        const send = (event: string, data: object) =>
          res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
        const ping = setInterval(() => res.write(': ping\n\n'), 15_000);           // keep proxies open
        try { await this.chat.reply(userId, id, String(text ?? '').slice(0, 4000), send, ac.signal); }
        finally { clearInterval(ping); res.end(); }
      }
    }

    The two headers are stand-ins so you can test with curl. In the real app, take the user and plan from your auth guard (Security essentials), never from headers the client controls.

  7. Wire the module

    TypeScript
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { PrismaClient } from '@prisma/client';
    import Redis from 'ioredis';
    import { ChatController } from './chat/chat.controller';
    import { ChatLimiter } from './chat/chat-limiter';
    import { ChatService } from './chat/chat.service';
    import { CHAT_PROVIDER, OpenAICompatibleProvider } from './llm/provider';
    
    @Module({
      controllers: [ChatController],
      providers: [
        ChatService, ChatLimiter,
        { provide: Redis, useFactory: () => new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') },
        { provide: PrismaClient, useFactory: () => new PrismaClient() },
        { provide: CHAT_PROVIDER, useFactory: () => new OpenAICompatibleProvider(
            process.env.LLM_BASE_URL!, process.env.LLM_API_KEY ?? '', process.env.LLM_MODEL!) },
      ],
    })
    export class AppModule {}
  8. Test it with a pretend model

    You do not need a real model or an API key to test the plumbing. This fake server streams in the same format at about 7 tokens a second and logs when the client goes away.

    TypeScript
    // scripts/fake-llm.ts: a pretend model server that streams like the real thing
    import { createServer } from 'node:http';
    
    createServer((req, res) => {
      let body = '';
      req.on('data', (c) => (body += c));
      req.on('end', async () => {
        const { messages } = JSON.parse(body);
        const words = `You asked: ${messages.at(-1).content}. Check the GRN screen, then press Post.`.split(' ');
        res.writeHead(200, { 'content-type': 'text/event-stream' });
        let closed = false;
        res.on('close', () => { closed = true; });
        for (const w of words) {
          if (closed) { console.log('fake-llm: client went away, stopped generating'); return; }
          await new Promise((r) => setTimeout(r, 150));
          res.write(`data: ${JSON.stringify({ choices: [{ delta: { content: w + ' ' } }] })}\n\n`);
        }
        res.write(`data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 42, completion_tokens: words.length } })}\n\n`);
        res.end('data: [DONE]\n\n');
      });
    }).listen(8001, () => console.log('fake LLM on :8001'));

    Start the fake model and the API with only 2 slots, create a conversation, then send four messages at once, and seven quick ones from a free user.

    Terminal
    npx ts-node scripts/fake-llm.ts &                     # the pretend model on :8001
    LLM_BASE_URL=http://localhost:8001 LLM_MODEL=fake MAX_ACTIVE=2 npm run start   # DATABASE_URL, REDIS_URL in .env
    
    CID=$(psql "$DATABASE_URL" -tA -c "INSERT INTO conversation (user_id) VALUES (4) RETURNING id" | head -1)
    
    curl -N -X POST localhost:3000/conversations/$CID/messages \
      -H 'x-user-id: 4' -H 'x-plan: plus' -H 'content-type: application/json' \
      -d '{"text":"How do I post a GRN?"}'
    Output
    event: start
    data: {}
    
    event: delta
    data: {"text":"You "}
    
    event: delta
    data: {"text":"asked: "}
    ...
    event: done
    data: {"inputTokens":42,"outputTokens":15}

    With four users sending at once and 2 slots, two start at once and two receive a place in line first. A free user's sixth and seventh quick messages are refused before any streaming begins:

    Output
    user 6:  event: start ... event: done
    user 10: event: start ... event: done
    user 8:  event: queued data: {"position":1}  event: start ... event: done
    user 4:  event: queued data: {"position":2}  event: start ... event: done
    
    free user, 7 quick messages:  201 201 201 201 201 429 429
    HTTP/1.1 429 Too Many Requests
    {"error":"rate_limited","retryAfterS":19}

    Finally, close a request early (curl --max-time 0.8). The fake model logs that the client went away and stops, and the partial answer is saved as stopped:

    SQL
    SELECT m.role, m.status, left(m.content, 28) AS content, m.output_tokens AS out
    FROM chat_message m JOIN conversation c ON c.id = m.conversation_id
    WHERE c.user_id IN (2, 4) ORDER BY m.id;
    Output
       role    |  status  |           content            | out
    -----------+----------+------------------------------+-----
     user      | complete | stock?                       |
     assistant | complete | You asked: stock?. Check the |  10
     user      | complete | long answer please           |
     assistant | stopped  | You asked: long answer pleas |

Bring it to your team

Ask these whenever a feature calls a language model.

  • Does the app call our server, which calls the model, or does the app hold a provider key? Who can extract that key?
  • What is the limit per user and per role, and what does the user see when they hit it?
  • How many model calls can run at once, what happens to the next one, and how long may it wait?
  • When the user closes the screen mid-answer, does the model call stop, and is the partial answer saved with a status?
  • Does every conversation query include the user ID, so a guessed ID returns nothing?
  • Are we storing token counts per message, so we can see cost per store, per feature and per day?

Red flags

  • A provider API key inside the mobile app or the web bundle.
  • Retrying a failed stream from the start after the user has already seen half the answer.
  • A queue with no length limit and no wait limit in front of the model.
  • "GPU utilisation is only 40%, so we have plenty of room" for a self-hosted model, without looking at memory and batch size.

Practice

Parse a token stream that arrives in pieces

The network does not deliver one SSE line at a time. A chunk can end in the middle of a line, and the next chunk carries the rest. Write parseStream(chunks), which reads an OpenAI-style stream like the one in step 2 and returns { text, done, usage }. Join every choices[0].delta.content piece into text. Skip blank lines and comment lines (they start with :). Set done to true at data: [DONE]. If a chunk carries usage, return it as { inputTokens, outputTokens } from prompt_tokens and completion_tokens; otherwise usage is null.

JavaScript exerciseRuns in your browser with tests
// chunks: strings exactly as they arrive from the network.
// A line can be split across two chunks. Lines starting with ':' are comments.
function parseStream(chunks) {
  let text = '';
  for (const chunk of chunks) {
    for (const line of chunk.split('\n')) {
      // TODO: handle "data: ..." lines, and lines cut in half between chunks
    }
  }
  return { text, done: false, usage: null };
}
Ctrl + Enter runs
Show a solution
JavaScript
function parseStream(chunks) {
  let buf = '', text = '', done = false, usage = null;
  for (const chunk of chunks) {
    buf += chunk;
    const lines = buf.split('\n');
    buf = lines.pop();                                  // keep a half line for the next chunk
    for (const line of lines) {
      if (done || !line.startsWith('data:')) continue;   // blanks, comments, anything after [DONE]
      const data = line.slice(5).trim();
      if (data === '[DONE]') { done = true; continue; }
      const json = JSON.parse(data);
      const content = json.choices?.[0]?.delta?.content;
      if (content) text += content;
      if (json.usage) {
        usage = { inputTokens: json.usage.prompt_tokens, outputTokens: json.usage.completion_tokens };
      }
    }
  }
  return { text, done, usage };
}
Pass the exercise to finish this task.

Build the admission check: a token bucket

Before any SSE bytes are sent, the proxy asks "may this user send a message now?" Write createBucket({ burst, perSecond }). It returns an object with tryTake(nowMs). The bucket starts full with burst tokens. On every call, first add perSecond tokens for each second since the previous call, never going above burst. If at least one token is left, spend it and return { allowed: true, retryAfterMs: 0 }. Otherwise return allowed: false and how many milliseconds until one full token is back (round up). Time is passed in, so the tests can control the clock.

JavaScript exerciseRuns in your browser with tests
function createBucket({ burst, perSecond }) {
  let tokens = burst;
  return {
    tryTake(nowMs) {
      // TODO: refill for the time since the last call, never above burst
      if (tokens >= 1) { tokens -= 1; return { allowed: true, retryAfterMs: 0 }; }
      return { allowed: false, retryAfterMs: 0 };          // TODO: when can they retry?
    },
  };
}
Ctrl + Enter runs
Show a solution
JavaScript
function createBucket({ burst, perSecond }) {
  let tokens = burst;
  let lastMs = null;
  return {
    tryTake(nowMs) {
      if (lastMs !== null) {
        const seconds = (nowMs - lastMs) / 1000;
        tokens = Math.min(burst, tokens + seconds * perSecond);   // refill, capped at burst
      }
      lastMs = nowMs;
      if (tokens >= 1) { tokens -= 1; return { allowed: true, retryAfterMs: 0 }; }
      const missing = 1 - tokens;                                 // part of a token still needed
      return { allowed: false, retryAfterMs: Math.ceil((missing / perSecond) * 1000) };
    },
  };
}

This is the same arithmetic as the Lua script in step 3. The Redis version exists because several API copies share one bucket per user, and Redis runs the whole check as one atomic step.

Pass the exercise to finish this task.

Check yourself

Why does running the decode step for many users together raise a GPU's tokens per second?

OpenAI runs one PostgreSQL primary with nearly 50 read replicas. What did it do about write-heavy workloads that can be split by key?

A user closes the app while an answer is streaming. What should your proxy do?

Flashcards

Open my review

Key takeaways

  • OpenAI publishes some parts (PostgreSQL, Kubernetes, incidents, API limits, streaming); the rest of the chat path is general LLM-serving knowledge.
  • Answers come in two phases: prefill reads the prompt in parallel, decode writes one token per step and is limited by memory speed, so batching many users raises throughput.
  • GPU memory holds each user's KV cache, which caps the batch; continuous batching refills free slots after every step.
  • GPUs cannot be autoscaled in a hurry, so limits per plan, queues and caps are the real levers during a spike.
  • At the core sits one boring PostgreSQL primary with replicas, PgBouncer, a stampede-proof cache and strict rules, with write-heavy work moved to sharded stores.

Sources

Episode transcriptOne oven, eight hundred million customers · about 11 minutes · tap any line to play from there
  1. NadiaImran, March twenty twenty-five. OpenAI launches image generation in ChatGPT. In one week, more than a hundred million new people sign up. Its CEO posts that their GPUs are melting.
  2. ImranAnd free users suddenly get three images a day.
  3. NadiaRight. Today's question is why a company with that much money can't just add servers, and what that teaches us about any feature built on a language model.
  4. ImranBefore we start, how much of ChatGPT's design is actually public?
  5. NadiaLess than you might hope. OpenAI has published its PostgreSQL setup, its Kubernetes clusters, some outage reports, and an engineer's interview about GPUs. The rest of the chat path we'll explain as general knowledge, how LLM serving usually works. I'll say which is which.
  6. NadiaHere's the picture. A kebab house on a Friday night, with one big flatbread oven. Picture one in Dhaka, Istanbul or Lagos. The oven is the expensive part. You can't buy a second one in ten minutes.
  7. NadiaAn order comes in. The cook reads the whole order and prepares the dough at once. That's quick. Then the flatbreads come out one at a time, each after the last. The waiter runs each one to the table as soon as it's ready.
  8. ImranSo the table starts eating before the order is finished. That's streaming.
  9. NadiaYes. And each table needs counter space for its half-finished order. The counter is small. When it's full, new customers wait at the door, or the manager says we're full tonight.
  10. ImranLet me map it. Oven is the GPU, the chip that runs the model. Flatbreads are tokens, the counter is GPU memory, and the manager at the door is the rate limiter.
  11. NadiaPerfect. Keep that kitchen in your head for the rest of the episode.
  12. ImranSo what happens when I press send?
  13. NadiaIf you can see your screen, the first diagram is a tall column of boxes. Phone on top, GPU at the bottom, and a blue line running back up the left side. That blue line is the tokens streaming back.
  14. NadiaFirst, the edge checks who you are and your plan's limit. For the API, OpenAI publishes these: requests and tokens per minute and per day, higher for bigger customers, and a four twenty-nine error with a retry-after header when you go over. That's a token bucket, the rate limiter you'll build in the practice section.
  15. NadiaNext, a service loads your conversation. The model remembers nothing between requests, so every turn sends the earlier messages again. Then safety checks, then a router picks a GPU server with room. If every server is full, you wait in a queue or you're refused.
  16. ImranThat's the "we are at capacity" message I used to see.
  17. NadiaAn OpenAI engineering manager, Evan Morikawa, said in twenty twenty-four that the message was literally true. There were no more GPUs to add.
  18. ImranOK. Now inside the GPU. Why is the first word slow and the rest fast?
  19. NadiaTwo phases. Prefill reads your whole prompt at once, in parallel. That keeps the GPU's maths busy, and a longer prompt means a longer wait for the first word. Then decode writes the answer one token per step.
  20. NadiaTo write token five hundred and one, the model needs something from all five hundred before it. It keeps those in GPU memory, in what's called the KV cache. One list of numbers per earlier token, per layer. It grows with every token.
  21. ImranSo the GPU must be busy doing maths the whole time.
  22. NadiaThat's the natural guess, and it's wrong. Morikawa gave the numbers. An H100 can move about three point three five terabytes a second from memory, and do about two quadrillion operations a second. Roughly five hundred and ninety operations for every byte it reads.
  23. NadiaOne user's decode step reads all the model's weights and does only a little maths with them. So the GPU mostly waits for memory.
  24. ImranThen you run many users together. One read of the weights serves the whole batch.
  25. NadiaExactly. Batching. Throughput goes up almost for free, until the counter fills. Every user in the batch needs their own KV cache. NVIDIA's guide works one example: a small seven-billion-parameter model, about four thousand tokens of text, needs about two gigabytes of cache for one request.
  26. ImranSo memory sets how many people one GPU can serve at once.
  27. NadiaMorikawa said GPU memory, not computing power, was ChatGPT's most common bottleneck. And plain GPU utilisation was a misleading number. They watched KV cache use and batch size instead.
  28. ImranAnd the second diagram?
  29. NadiaTwo panels, each with four slots. In the top panel, static batching. Four requests start together, and nothing new can join until the longest one finishes. You see grey dashed gaps where slots sit idle. Request E only finishes at step twelve.
  30. NadiaIn the bottom panel, continuous batching. The moment request D finishes, E takes its slot. E finishes at step six. Same GPU, more answers, and less waiting.
  31. ImranThat's the good cook filling any gap in the oven.
  32. NadiaYes. A research paper called Orca proposed it in twenty twenty-two. A year later vLLM stored the KV cache in small pages, like an operating system's memory, so less is wasted and more people fit. We'll see how that same trick enables prompt caching in the next episode.
  33. ImranNow the part our database team will ask me about. What database does ChatGPT use?
  34. NadiaThis is published, in January twenty twenty-six, and it's encouraging. One PostgreSQL primary on Azure, nearly fifty read replicas across regions, millions of queries a second. PgBouncer in front. They report the average connection time fell from fifty milliseconds to five.
  35. ImranOne primary? For eight hundred million users a week?
  36. NadiaOne writer. Reads go to the replicas. And they say plainly that a single primary can't scale writes. So the write-heavy workloads that can be split by key moved to sharded systems, and no new tables may be added to the Postgres cluster.
  37. NadiaThey also use a cache stampede lock. When many requests miss the same cache key at once, only one of them goes to Postgres. And schema changes have a five-second timeout, with no full table rewrites.
  38. ImranSo a boring Postgres with replicas, a pooler and firm rules carries a very long way. You change plans when writes hit the ceiling, not reads.
  39. NadiaThat's the takeaway. Now a failure story. December eleventh, twenty twenty-four. ChatGPT and the API were down or degraded for four hours and twenty-two minutes.
  40. NadiaA new telemetry service rolled out to every node. On each node it made calls to Kubernetes whose cost grew with cluster size. Thousands of nodes at once overwhelmed the Kubernetes API servers. Service discovery depended on those servers, so services couldn't find each other.
  41. ImranAnd they couldn't roll it back?
  42. NadiaTo roll back, they needed the Kubernetes API, which was the thing that was overloaded. And DNS caching hid the problem at first, so the rollout kept going. Their fixes: slower phased rollouts, break-glass access, and a data plane that keeps working when the control plane is sick.
  43. ImranSo keep the brain and the muscle apart. The part that decides where work runs shouldn't be able to stop the part doing the work.
  44. ImranSo what do we build?
  45. NadiaA small streaming chat proxy in NestJS that works with any model. First, two tables: conversation, and chat message with a status column. Complete, stopped or failed, plus token counts.
  46. NadiaSecond, one provider class that speaks the common OpenAI-style streaming format, so you can point it at a hosted model or your own server running vLLM. Third, a token bucket per user and plan, kept in Redis so every copy of the API shares it.
  47. NadiaFourth, a capacity gate. Only a few model calls run at once, a short line waits with its position shown, and the rest get a polite busy message right away. Fifth, the endpoint streams events back and listens for the user closing the screen.
  48. ImranAnd when the user closes the screen?
  49. NadiaThe proxy aborts the model call, so you stop paying, releases the slot for the next person, and saves the half answer as stopped. You test it all with a fake model server that streams about seven tokens a second.
  50. ImranCan I try any of it without a GPU or an API key?
  51. NadiaYes. The practice section has two small exercises that run in your browser. One parses a stream that arrives in broken pieces, where a line can be cut in half between two network chunks. The other is the token bucket: a burst, a refill rate, and an honest retry time.
  52. ImranFor my team. First: does our app hold the provider key, or does our server? A key inside the mobile app can be extracted by anyone.
  53. ImranSecond: when two hundred staff across our stores ask the assistant at once during a training session, what happens to the two hundred and first? Third: does every conversation query include the user ID?
  54. NadiaWhy the third?
  55. ImranBecause in March twenty twenty-three, a bug in a Redis client library showed some ChatGPT users other people's chat titles. One fix OpenAI listed was checking that cached data belongs to the user asking.
  56. NadiaGood memory. Here's one to think about. The model fails halfway through an answer. Should your proxy retry the call automatically? Take a second.
  57. NadiaNot from the start. The user has already seen half the answer, and a retry would repeat it, or give a different first half. Retry only before the first token. After that, show an error and save the partial answer as failed.
  58. ImranLet me sum up. One: the model answers in two phases, prefill reads, decode writes one token at a time. Two: GPUs are limited by memory, so batching and continuous batching matter. Three: you can't autoscale GPUs, so limits and queues are the real controls.
  59. NadiaOne nuance on three. Those limits are a product decision. Someone has to decide who waits, free users or paying ones. That isn't only an engineering choice.
  60. ImranNext episode: Claude, and how Anthropic serves one model on three kinds of chips.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Capacity plan

Expected peak traffic, a sized design and Find my limit, with headroom and cost per month as criteria.

Open in the System Design Studio
Next stopClaude: how Anthropic serves Claude at scale
Was this lesson helpful?
Questions and discussion

Loading the discussion…