System Design Bits

Support the project

System Design Bits is free and always will be. If it helped, a coffee helps back.

Buy Me a Coffee QR codeBuy me a coffee

Capstone·19 min read

How a Backend Is Built Around One Message

Ten backend pieces, one chat message, and the exact thing each piece is the only one that can do.

Big Idea

Servers, APIs, databases, caches, WebSockets, queues, load balancers, containers, architecture, observability. Drawn as a stack they look like the parts list for a real backend. They are not a list, they are ten answers to ten different questions, and one message moving from one phone to another asks all ten in order. Nadia types running ten minutes late and hits send. Everything below is what has to exist for Theo to read it, and nothing below exists until the version before it runs out.

The Shape Of It

Why The Phones Can’t Just Talk To Each Other

The obvious design is the one that does not work: Nadia’s phone handing the message straight to Theo’s. Phones make terrible delivery targets. Neither one has an address the other could dial, both get a new one every time they move between wifi and cellular, and Theo’s is locked in a pocket most of the day.

So neither phone tries. They both talk to one machine that is none of those things, and that machine is the entire backend on day one.

Why both phones talk to a server instead of to each otherNadia’s phonehas the messageTheo’s phoneneeds the messagedirect deliveryneither has an address the other can reachboth get a new IP on every network they joinboth are asleep most of the daysenddeliverOne serveralways listening
At two users that single box is the whole backend, and it is the right one: one process to deploy, one place to look when something breaks, and no second machine that can disagree with it. Everything else on this page is what gets added when that stops being enough, in the order it stops being enough.

Interactive

Watch The Backend Grow, One Piece At A Time

One canvas, ten pieces. Press play, or step through it. Nothing ever moves, it only arrives, and the badge on each box is the piece that put it there.

Piece 1ServerA message with nowhere to go

One always-on process, with both phones talking to it instead of to each other.

The backend after piece 1, ServersendNadiasends1Theoreceives1App servershold the sockets1
Piece ten is what a backend diagram usually is: not a design, but a record of ten questions somebody had to answer, drawn all at once so the order is no longer visible. The ten cards below are that order.

The Walkthrough

Ten Pieces, And What Each One Charges

1
ServerA message with nowhere to go

What it can’t do yet

Nadia's phone holds the message and Theo's phone has to receive it. A phone is the worst delivery target there is: it sits behind carrier NAT with no address anyone can reach, it changes IP the moment it changes network, and it is asleep most of the day.

The piece

A server: a process on a machine that stays on, listening on a port, accepting requests and returning responses. Both phones talk to it and never to each other. Your backend code runs there, in Go, Java, Python, Node or Elixir, and the choice of language changes nothing about this shape.

What it costs

Something now has to stay up, and it is yours: deploys, patches, restarts, and a bill per hour whether anyone sends a message or not. On day one it is also a single point of failure, and every piece below exists because this one process cannot do everything well.

↗Deep dive: The Lifecycle of a Software Request
2
APIWhat may be asked

What it can’t do yet

The phone can reach the server and could send it literally anything. The server cannot branch on arbitrary data, and a client that guesses the shape wrong gets a 500 instead of a usable answer.

The piece

An API: a fixed set of named operations with defined inputs, outputs and auth rules. POST /messages, GET /chats, POST /auth/login. Both sides agree on the list, so the server validates one shape per route and the phone knows in advance what it will get back.

What it costs

A contract you no longer control alone. A build from eight months ago is still calling the old shape and cannot be made to update, so fields get added rather than changed, and the one route you regret ships forever.

↗Deep dive: API Design: REST, GraphQL, and gRPC
3
DatabaseSurviving a restart

What it can’t do yet

Messages live in a list in memory, so the next deploy, crash or routine restart takes every conversation on the service with it. Not corrupts them: deletes them, with no trace that they were ever there.

The piece

A database, Postgres or MySQL or MongoDB, writing to disk and indexing what you actually search by, so 'the messages in this chat, newest first' is a lookup instead of a scan over everything ever sent.

What it costs

The data now sits across a network from the code, with a connection pool, a schema to migrate, and a disk that can fill up at 3am. It is also the one piece whose loss cannot be papered over, because everything else in this list can be rebuilt by deploying again.

↗Deep dive: Selecting Relational vs NoSQL Database Models
4
CacheThe same read, a thousand times

What it can’t do yet

Every app launch asks for the same things: recent chats, unread counts, display names. A group of forty people runs the identical query forty times a minute, and the database sits at 90% CPU while the app servers are half idle.

The piece

Redis between the code and the database, holding hot answers in memory under a short TTL. Most reads stop there, a miss falls through to the database, and the result is written into the cache on the way back out.

What it costs

A second copy of the truth, which every write path now has to invalidate. When one path forgets, nothing errors and nothing pages: the system keeps serving a wrong answer quickly, which is the hardest class of bug to see from the outside.

↗Deep dive: Caching Strategies & Cache Invalidation
5
WebSocketsTelling Theo

What it can’t do yet

Theo's app has no idea the message exists. Asking the server every three seconds works, and is almost entirely waste: nearly every answer is empty, the radio wakes for each one, and a message still lands up to three seconds after it was sent.

The piece

A WebSocket: one connection, opened once and kept open, carrying frames in both directions. The server pushes the message the instant it is stored, and nobody has to ask for anything.

What it costs

The connection is state, and state has an address. Theo's socket is held by exactly one process, so that process can no longer be restarted casually, a deploy drops every socket it was holding, and the client needs reconnect-with-backoff plus a way to fetch whatever arrived while it was gone.

↗Deep dive: Choosing a Communication Pattern
6
Message queueThe phone is locked

What it can’t do yet

There is no socket, because the app is not running. Reaching Theo now means an outbound call to Apple's or Google's push service: a few hundred milliseconds on a good day, a timeout on a bad one, under rate limits you do not control. Nadia is watching a spinner for all of it.

The piece

The server stores the message, drops a job onto a queue, and returns immediately. A background worker picks the job up, calls the push service, and retries with backoff when that call fails. The slow, failure-prone dependency comes off the path the sender is waiting on.

What it costs

Delivery is no longer finished when the response says so, which the interface has to admit: Sent is not Delivered. Queues are at-least-once, so a worker that dies after sending will send again, which means every job has to be safe to run twice. And a job that keeps failing needs somewhere to go other than the retry loop.

↗Closest deep dive: Architectural Styles: Monolith to Event-Driven
7
Load balancer and Pub/SubMore sockets than one box holds

What it can’t do yet

A single machine runs out of memory and file descriptors long before it runs out of users, and every socket is held for hours rather than milliseconds. Ten machines fix the capacity and break the delivery: Nadia's request lands on server 1, Theo's socket is on server 7, and server 1 has no way to write to it.

The piece

A load balancer in front, spreading connections across the ten and health-checking each one so a dead server stops receiving traffic. Then a shared Pub/Sub channel, in Redis or NATS or Kafka: server 1 publishes the message, all ten receive it, and the one holding that socket delivers it.

What it costs

Two more pieces that must not die, and a fan-out that grows with the fleet rather than with the recipients: every server sees every message and discards the ones it has no socket for. Deploys now need connection draining, because restarting the fleet means disconnecting everybody.

↗Deep dive: Core Concepts of Load Balancing
8
ContainersTen machines, one setup

What it can’t do yet

Server 4 has a different runtime version than the other nine and fails a request they all handle. Nothing is wrong with the code. The machines drifted apart over six months of manual fixes, and the fix that matters only exists in one engineer's shell history.

The piece

Docker: the code plus its runtime, libraries and config, built once into an image that runs the same anywhere. A scheduler then places copies of that image across machines, restarts the ones that die, and replaces them a few at a time on deploy.

What it costs

An orchestrator is itself a distributed system, so now you operate two of them: images, registries, resource limits, rollout strategy, and a new class of incident where the app is perfectly healthy and the scheduler is not. For most teams the honest version of this stage is a managed platform that runs the image for you.

↗Closest deep dive: Scalability: Vertical vs Horizontal Scaling
9
ArchitectureOne codebase, four jobs

What it can’t do yet

Login, chat, media and notifications all live in one deployable, which was an advantage for years. It stops being one the day video transcoding needs CPU-heavy machines while chat needs cheap memory-heavy ones, and a transcoding fix has to wait for a chat release to be safe.

The piece

Split the piece that has its own pressure, not the org chart. Media becomes its own service with its own hardware and release cadence. Login, chat and presence stay in one codebase, because a function call between them was never the problem.

What it costs

Every split turns a call that could not fail into a network call that can: timeouts, retries, partial failures, and a transaction that no longer exists. Local development needs more than one thing running. The modular monolith, clear internal boundaries inside one deployable, is where most of this benefit lives without the bill.

↗Deep dive: Architectural Styles: Monolith to Event-Driven
10
ObservabilityThirty seconds late

What it can’t do yet

Messages are arriving half a minute after they were sent. Every service reports healthy, API p99 is 60ms, the error rate is zero, and not one person in the incident channel can say which of the nine pieces above is holding the message.

The piece

Three different things that are not interchangeable. Metrics are numbers over time, latency percentiles and error rate and queue depth, and they tell you something is wrong. Logs record what happened in one place, tied together by a request ID. A trace follows one message across every hop and shows where the time actually sat.

What it costs

The only piece here that adds no capability at all, and it still charges: storage per log line, cardinality limits on metrics, sampling decisions, and instrumentation in code that was working fine without it. It is also what makes the other nine debuggable, which is why it is the worst one to defer.

↗Closest deep dive: Latency, Throughput & Availability
Message queues, containers and observability do not have a page of their own here yet, so pieces six, eight and ten point at the nearest concept rather than a dedicated one.

Worked Example

Asking, Versus Being Told

Theo’s app has to find out the message exists. Both versions below work. The difference is who speaks first, and that one change removes the empty round trips and the delay at the same time.

Polling: the client asks, over and over

Polling: mostly empty answers, and up to three seconds of delayNadiaTheoApp serverGET /messages[] nothing3 secondsGET /messages[] nothingsend, storedit sits here, nobody is askingGET /messages1 messageone useful poll in four, and up to 3s late

WebSocket: the server speaks first

One open connection, and a push the moment the message is storedNadiaTheoApp serveropen socket, onceheld open, both directionssendstored, 14mspush: 1 message200 OKarrives in ~40ms, zero empty round trips
What the socket costs is on the server, not the phone. That connection is held by one specific process, so an app server is no longer a thing you can restart whenever you like, and a deploy drops every connection it was holding. Which is the next two problems on this page.

Worked Example

The Locked Phone, And Whose Outage It Is

There is no socket, because Theo’s app is not running. Reaching him means calling Apple or Google, which means a request to a company you do not operate. Leaving that call on the request path hands them your send button.

Inline: Nadia waits for Apple

A third-party timeout turning a stored message into a failed sendNadiaApp serverAPNs / FCMsendstored, 14msnotify Theo840ms, then a timeout504send failedthe message is stored. Nadia was told it wasn't.

Queued: the slow dependency moves off the request

The same timeout, absorbed by a retry instead of a failed sendNadiaApp serverQueueWorkersendstored, 14msenqueue notify200 OK, 40msNadia is done herejobsame timeout, retry in 2sdelivered on the retry. Nobody waited.
The queue did not make the push faster, it changed what a slow push means: a retry instead of a failed send. The bill arrives in two places. The interface now has to distinguish sent from delivered, because they happen at different times. And queues are at-least-once, so that worker will eventually run twice on the same job, which has to be harmless rather than a duplicate notification at 3am.

Worked Example

Ten Servers, And The One Holding The Socket

One machine runs out of memory and file descriptors long before it runs out of users, because every socket is held for hours rather than milliseconds. Ten machines fix that and break delivery: a connection lives on exactly one process, and the server handling Nadia’s send is almost never the one holding Theo’s socket.

The load balancer does not help here. It decides where new connections go, and has no way to write into one that is already open somewhere else.

Reaching a socket held by a different server, through a shared channelNadiaServer 1Pub/SubServer 7Theosendno socket for Theo herepublishall 10 receive itthis one holds itpushthe other nine drop it on the floorevery server sees every message: that is the price
Notice what grows. The fan-out scales with the size of your fleet, not with the number of recipients, so adding servers makes each message cost more internally. Teams past a certain size stop broadcasting and keep a registry of which server holds which connection, which is faster and is now one more piece of state that can be wrong.

Worked Example

Thirty Seconds Late, And Every Dashboard Green

Messages are arriving half a minute after they were sent. API p99 is 60ms, the error rate is zero, every service reports healthy, and all of that is true. Per-service metrics measure components answering for themselves, and nothing was slow inside a component. The message was waiting between them, and a queue is not a service with a latency number.

A trace follows the one message across every hop and puts the time where it actually went:

One message, traced across every hop0s7.5s15s22.5s30sPOST /messages61msauth check (cache hit)3msinsert message14mspublish to Pub/Sub4msenqueue notify job5mswaiting in queue29.4sworker: call APNs280mspush delivered to Theo90ms
Every unit of real work on this trace adds up to under a second. The other 29.4 seconds is a wait, and the fix is not faster code, it is more workers or fewer jobs. Two things would have caught it without a trace, and neither is a latency percentile: queue depth, the one metric that describes a backlog, and a request ID carried through every log line so the hops can be lined up after the fact. Instrument the gaps between the boxes, not just the boxes.

Recap

The Whole Path

Read straight down. Each line is what that piece does, and the line between is what it still could not do — which is the piece underneath it.

1Serverruns the code
but it will take literally anything the phone sends
2APIdefines the requests
but a restart wipes every conversation
3Databasesaves the message
but the same handful of reads keeps repeating
4Cachespeeds up reads
but nobody has told Theo anything yet
5WebSocketsdeliver it live
but his phone is locked, and Apple is slow to answer
6Message queuehandles slow work
but one machine cannot hold every connection
7Load balancersspread traffic
but ten machines drift apart from each other
8Containersrun it everywhere
but one codebase is now doing four different jobs
9Architecturesplits it up
and now nobody can say which piece is slow
10Observabilityshows what’s happening
A backend grows one piece at a time. Each new piece gets added right when the last one hits its limit.

Key Points

What To Carry Forward

✓

Each piece answers exactly one question: where does the code run, what may be asked, what survives a restart, what repeats, who gets told, what can wait, which machine, which environment, which codebase, where did the time go.

✓

A database makes a message durable and a cache makes a read cheap. Neither of them delivers anything — delivery is the socket's job, and that separation is the whole reason both exist.

✓

Polling and WebSockets both work. One pays per empty check, the other pays by holding state, and that state is what stops an app server from being freely restartable.

✓

A queue does not make the push notification faster. It moves it off the path the sender is waiting on, and buys retries, duplicates, and a message that is 'sent' before it has been sent.

✓

The moment there are two servers, delivery becomes routing: a socket lives on exactly one process, so every other server needs a channel to reach it.

✓

Observability is the only piece that adds no capability at all. It is what makes the other nine debuggable, which is why it is the one that should never be last.

Quiz Review

Check your understanding

Question 1 of 6

A chat app keeps messages in a list in memory and delivers them over WebSockets. Everything passes in testing. What does the first real deploy break, and which piece fixes which half of it?

  • ✓Both halves of the same restart. The list in memory is gone, which deletes every conversation on the service, and that is what a database fixes: the data outlives the process. The open sockets are also gone, and no storage layer helps there, because a socket cannot be persisted. That half is fixed on the edges of the restart instead: drain connections before the process stops, and have the client reconnect with backoff and ask for anything it missed while it was disconnected. Durability and connectivity look like one bug here and are two different pieces.