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
| Metric | Number | Source, year |
|---|---|---|
| People using ChatGPT each week | more than 800 million | Sam Altman at OpenAI DevDay, reported by TechCrunch, 2025 |
| Tokens processed by the API | more than 6 billion a minute | same, 2025 |
| New sign-ups during the image generation launch | over 100 million in one week | OpenAI engineering, 2026 |
| PostgreSQL | one primary, nearly 50 read replicas, millions of queries a second | OpenAI engineering, 2026 |
| Growth in PostgreSQL load | more than 10× in one year | OpenAI engineering, 2026 |
| Largest Kubernetes clusters (research) | 7,500 nodes | OpenAI, 2021 |
| GPU used as an example (NVIDIA H100) | 3.35 TB/s memory speed, 1.98 quadrillion operations a second | OpenAI'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.
- 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-Afterheader. 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. - 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.
- 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.
- 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.
- 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.
- Tokens stream back. Published for the API: with
stream: true, answers arrive as Server-Sent Events, one small chunk after another, ending withdata: [DONE](Real-time updates). The first words appear while the rest is still being generated. - 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.
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 token 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 KV cache. 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:
- Prefill 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 time to first token grows with prompt length.
- Decode 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?
Prefill processes the whole prompt before the first token appears, so a long paste mainly delays the start. Decode does not re-read the text for each word; it uses the KV cache that prefill built, though a bigger cache does make each step a little heavier.
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 continuous batching. 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?
Batch size is capped by the memory left for each request's KV cache. When that memory is full, new requests must wait even though the maths units look idle. That is why Morikawa called plain utilisation misleading.
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 read replicas 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 MVCC 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 PgBouncer 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?
Every write still goes to the single primary, so replicas cannot absorb a write spike. They do spread reads across regions and give spare copies when one fails. That is why OpenAI moved write-heavy work to sharded systems instead.
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
- 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).
- 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).
- 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.
-
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.
SQLCREATE 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);Prismamodel Conversation { id String @id @default(uuid()) @db.Uuid userId Int @map("user_id") title String? createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) messages ChatMessage[] @@index([userId, createdAt]) @@map("conversation") } model ChatMessage { id BigInt @id @default(autoincrement()) conversationId String @map("conversation_id") @db.Uuid role String // 'user' | 'assistant' content String status String @default("complete") // 'complete' | 'stopped' | 'failed' inputTokens Int? @map("input_tokens") outputTokens Int? @map("output_tokens") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) conversation Conversation @relation(fields: [conversationId], references: [id]) @@index([conversationId, id]) @@map("chat_message") } -
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 withdata: [DONE]. This class reads that stream and turns it into simple events. Thesignalmatters: when it fires,fetchcloses 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 }; } } -
Limit each user by plan
Reuse the atomic token bucket Lua script from Rate limiting (save it as
token_bucket.luanext to this file and copy it intodiston 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); } } -
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
maxActivecalls run, keeps up tomaxWaitingin line for at mostmaxWaitMs, 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
maxActive8, up to 32 calls run. For one shared limit across copies, use a Redis-based semaphore, or let your model server queue requests itself. -
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
finallyalways 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).
-
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 withfetchand a stream reader, sinceEventSourceonly does GET. When the user closes the tab,closefires, 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.
-
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 {} -
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.
Terminalnpx 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?"}'Outputevent: 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:
Outputuser 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 asstopped:SQLSELECT 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;Outputrole | 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.
// 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 };
}
const piece = (s) => `data: ${JSON.stringify({ choices: [{ delta: { content: s } }] })}\n\n`;
test('joins the pieces in order', () => {
const r = parseStream([piece('Post '), piece('the '), piece('GRN.'), 'data: [DONE]\n\n']);
expect(r.text).toBe('Post the GRN.');
});
test('handles a line split across two network chunks', () => {
const line = piece('Hello');
const r = parseStream([line.slice(0, 20), line.slice(20), 'data: [DONE]\n\n']);
expect(r.text).toBe('Hello');
});
test('skips comments and blank lines, and marks done', () => {
const r = parseStream([': ping\n\n', piece('Hi'), '\n', 'data: [DONE]\n\n']);
expect(r).toEqual({ text: 'Hi', done: true, usage: null });
});
test('reads token usage from the usage chunk', () => {
const usage = 'data: {"choices":[],"usage":{"prompt_tokens":42,"completion_tokens":3}}\n\n';
const r = parseStream([piece('Yes'), usage, 'data: [DONE]\n\n']);
expect(r.usage).toEqual({ inputTokens: 42, outputTokens: 3 });
});
test('a stream cut off before [DONE] is not done', () => {
const r = parseStream([piece('Half an ans')]);
expect(r).toEqual({ text: 'Half an ans', done: false, usage: null });
});
Show a solution
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 };
}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.
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?
},
};
}
test('a full bucket allows a burst, then refuses', () => {
const b = createBucket({ burst: 5, perSecond: 0.25 });
const allowed = [0, 0, 0, 0, 0, 0].map((t) => b.tryTake(t).allowed);
expect(allowed).toEqual([true, true, true, true, true, false]);
});
test('says how long to wait before retrying', () => {
const b = createBucket({ burst: 1, perSecond: 0.25 }); // 15 messages a minute
b.tryTake(0);
expect(b.tryTake(0).retryAfterMs).toBe(4000);
expect(b.tryTake(2000).retryAfterMs).toBe(2000);
});
test('refills over time', () => {
const b = createBucket({ burst: 1, perSecond: 0.25 });
b.tryTake(0);
expect(b.tryTake(3000).allowed).toBe(false);
expect(b.tryTake(4000).allowed).toBe(true);
});
test('never holds more than the burst, however long the user waits', () => {
const b = createBucket({ burst: 2, perSecond: 0.5 });
const allowed = [0, 600000, 600000, 600000].map((t) => b.tryTake(t).allowed);
expect(allowed).toEqual([true, true, true, false]);
});
Show a solution
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.
Check yourself
Why does running the decode step for many users together raise a GPU's tokens per second?
Decode does little maths per byte of weights read, so one user leaves the GPU mostly waiting on memory. A batch reuses each read for every user in it. Each user still needs their own KV cache, which is why memory, not maths, sets the batch size.
OpenAI runs one PostgreSQL primary with nearly 50 read replicas. What did it do about write-heavy workloads that can be split by key?
Replicas only help reads; every write still goes to the one primary. OpenAI moved the shardable write-heavy workloads out and froze new tables on PostgreSQL. Turning off autovacuum would make MVCC bloat, already a stated pain, much worse.
A user closes the app while an answer is streaming. What should your proxy do?
Generating for nobody wastes tokens and holds a slot that a waiting user needs. Saving the partial text with a status keeps an honest record. A disconnect from the user's side is not a provider failure, so retrying makes no sense.
Flashcards
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
- Sam Altman says ChatGPT has hit 800M weekly active users, TechCrunch, 2025
- Scaling PostgreSQL to power 800 million ChatGPT users, OpenAI, 2026
- Scaling Kubernetes to 7,500 nodes, OpenAI, 2021
- Scaling ChatGPT: Five Real-World Engineering Challenges, Gergely Orosz with Evan Morikawa (OpenAI), The Pragmatic Engineer, 2024
- API, ChatGPT and Sora facing issues: incident write-up, OpenAI status page, 2024
- March 20 ChatGPT outage: Here's what happened, OpenAI, 2023
- ChatGPT reduced availability: incident write-up, OpenAI status page, 2025
- Rate limits, OpenAI API documentation, read in 2026
- Streaming API responses, OpenAI API documentation, read in 2026
- Moderation, OpenAI API documentation, read in 2026
- Sam Altman says ChatGPT's GPUs are melting, Fortune, 2025
- Mastering LLM Techniques: Inference Optimization, NVIDIA Technical Blog, 2023
- Orca: A Distributed Serving System for Transformer-Based Generative Models, Yu et al., OSDI 2022
- Efficient Memory Management for Large Language Model Serving with PagedAttention, Kwon et al., SOSP 2023
- vLLM online serving (OpenAI-compatible server), vLLM documentation, read in 2026