Backend engineeringHow systems talk14 protocols, side by side
Protocols
Unpacked
Every way two programs can talk, from a plain HTTP request to brokers, streams and peer to peer calls. Each protocol gets a one line idea, a plain explanation, the technical detail, a sequence diagram and a working example you can replay.
The map
Four families cover every protocol here. Start with the shape of the conversation you need, then pick a member.
The client asks, the server replies, and the call is over.
Updates flow to you without asking for each one.
One open connection where either side speaks any time.
HTTP, the road underneath
Most protocols here ride on HTTP, so its three versions decide how fast everything above them feels.
Like ordering at a counter. You hand over a slip (the request) with a short header and maybe a bag of content, and you get a slip back (the response) with a status number telling you how it went.
HTTP/1.1 sends text messages over TCP and handles one request at a time per connection, so browsers open about six connections per site. HTTP/2 multiplexes many streams over one TCP connection using binary frames and HPACK header compression, but one lost packet stalls every stream (TCP head of line blocking). HTTP/3 runs on QUIC over UDP with TLS 1.3 built in, so streams lose packets independently and connections survive a network change.
Pick it forAlways, underneath. Prefer HTTP/2 or 3 for browsers and APIs with many parallel calls.
- Transport
- TCP (1.1, 2) · QUIC on UDP (3)
- Payload
- Headers plus any body
- Direction
- Client asks, server answers
- Ports
- 80 · 443
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
HTTP/1.1 | curl --http1.1 URL | Plain text, supported by every client and proxy, easy to read in a packet capture | Legacy clients, quick debugging, simple internal tools |
HTTP/2 | curl --http2 URL | Many requests share one TCP connection, with compressed headers | The default for browsers and APIs over TLS; gRPC requires it |
HTTP/3 | curl --http3 URL | QUIC avoids TCP head of line blocking and survives a Wi-Fi to mobile switch | Mobile users, lossy networks, global CDNs |
Keep-alive pool | new https.Agent({ keepAlive: true }) | Reuses warm connections instead of a new TCP and TLS handshake per call | Any service calling the same host repeatedly |
Conditional GET | If-None-Match: "v7" → 304 | The server skips the body when nothing changed | Caching and cheap change checks |
Try it
curl -sI https://example.com # headers only
curl -sI --http2 https://example.com
curl -s -o /dev/null -w "%{http_version}\n" https://example.com
curl -sI --http3 https://cloudflare.com # needs curl built with HTTP/3
Read the status class first. 2xx worked, 3xx look elsewhere, 4xx the request was wrong, 5xx the server failed. The exact code only matters after that.
# a recorded session, replayed when you press Run curl -sI --http2 https://example.com HTTP/2 200 content-type: text/html cache-control: max-age=3036 curl -s -o /dev/null -w "%{http_version}\n" https://example.com 2
Key terms
| Term | Simple meaning |
|---|---|
Method | The verb: GET reads, POST creates, PUT replaces, PATCH edits, DELETE removes |
Status code | The outcome as a number, such as 200 or 404 |
Header | A labelled detail about the message, like Content-Type |
Idempotent | Repeating it gives the same result: GET, PUT, DELETE |
Multiplexing | Many requests in flight on one connection |
Head of line blocking | One slow or lost packet holds up everything behind it |
REST
A style, not a protocol: every resource lives at a URL, and the standard HTTP verbs act on it.
Like a library catalogue. Every book has its own address, /books/42, and the same few verbs work on any book: read it, add one, change it, remove it. Learn the verbs once and every REST API feels familiar.
Defined by Roy Fielding in 2000 as a set of constraints: client and server, stateless requests, cacheable responses, a uniform interface and layered systems. Clients transfer representations of resources, usually JSON. Methods carry the meaning, status codes carry the outcome, ETag and Cache-Control drive caching, and OpenAPI documents the contract. Versioning lives in the URL (/v2) or a header.
Pick it forPublic APIs, CRUD services, anything that third parties or many languages must call.
- Transport
- HTTP/1.1 or HTTP/2
- Payload
- JSON, sometimes XML
- Direction
- Request, then response
- Contract
- OpenAPI, optional
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
GET | GET /users?page=2&limit=20 | Safe and cacheable, so CDNs and browsers can reuse answers | Lists, details, search |
POST | POST /users | Creates a resource and lets the server choose its id | New records, or actions such as POST /orders/7/cancel |
PUT | PUT /users/42 | Replaces the whole resource and is idempotent, so retries are safe | The client holds the full object, or upserts by a known id |
PATCH | PATCH /users/42 {"name":"Asha R"} | Sends only what changed, so payloads stay small | Edit forms and partial updates |
DELETE | DELETE /users/42 → 204 | Idempotent removal; a second call changes nothing | Removing resources |
Idempotency-Key | Idempotency-Key: 8f2c1d… | The server stores the first result and replays it for a retried POST | Payments and orders over flaky networks |
Try it
curl -s https://api.example.com/users/42
curl -si -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-d '{"name":"Asha","email":"asha@example.com"}'
curl -s -X PATCH https://api.example.com/users/42 \
-H "Content-Type: application/json" -d '{"name":"Asha R"}'
curl -s -o /dev/null -w "%{http_code}\n" -X DELETE https://api.example.com/users/42
POST answers 201 with a Location. A well behaved API tells you where the new resource lives, so the client never has to guess its URL.
# a recorded session, replayed when you press Run curl -s https://api.example.com/users/42 {"id":42,"name":"Asha","email":"asha@example.com"} curl -si -X POST https://api.example.com/users -H "Content-Type: application/json" -d '{"name":"Ravi"}' HTTP/2 201 location: /users/43 content-type: application/json {"id":43,"name":"Ravi"} curl -s -o /dev/null -w "%{http_code}\n" -X DELETE https://api.example.com/users/43 204
Key terms
| Method | Does | Safe | Idempotent |
|---|---|---|---|
GET | Read a resource | Yes | Yes |
POST | Create, or run an action | No | No |
PUT | Replace the whole resource | No | Yes |
PATCH | Change some fields | No | Not by default |
DELETE | Remove it | No | Yes |
SOAP
Strict XML envelopes and a machine readable contract, still running banks, telecoms and government systems.
Like sending an official letter in a fixed envelope. Every message has the same envelope, an optional header for stamps and signatures, and a body with the request. A WSDL file is the office rulebook saying which letters it accepts and what it sends back.
SOAP 1.2 (W3C) defines Envelope, Header, Body and Fault. Messages are usually POSTed to one endpoint, with the operation named by SOAPAction (1.1) or the action parameter of application/soap+xml (1.2). WSDL describes operations and XSD types, so clients are generated from it. WS-* standards add message level security (WS-Security signatures and encryption), reliable delivery and transactions. Errors come back as a soap:Fault.
Pick it forIntegrating with enterprise or regulated systems that already publish a WSDL.
- Transport
- HTTP POST, sometimes SMTP or JMS
- Payload
- XML envelope
- Direction
- Request, then response
- Contract
- WSDL plus XSD, required
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Document/literal | style="document" use="literal" | The Body is validated against an XSD schema and passes WS-I checks | The default for any new SOAP service |
RPC/literal | style="rpc" use="literal" | The Body wraps the operation name around its parameters | Older services designed around method calls |
SOAP 1.1 | Content-Type: text/xml + SOAPAction | The widest tool support | Older enterprise and government endpoints |
SOAP 1.2 | application/soap+xml; action="…" | A W3C standard with a cleaner fault model | New integrations when both sides support it |
WS-Security | <wsse:Security> in the Header | Signs and encrypts the message itself, so it stays protected through intermediaries | Banking, healthcare, government |
MTOM | XOP attachments | Sends binary as raw parts instead of bulky base64 text | PDFs, scans and images in SOAP calls |
Try it
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:acc="http://bank.example.com/accounts">
<soap:Header>
<acc:Auth token="eyJhbGciOiJIUzI1NiJ9"/>
</soap:Header>
<soap:Body>
<acc:GetBalance>
<acc:AccountId>ACC-1042</acc:AccountId>
</acc:GetBalance>
</soap:Body>
</soap:Envelope>
The envelope never changes shape. Only the namespaced elements inside Body differ per operation, which is what lets tools validate every message against the WSDL.
# a recorded session, replayed when you press Run curl -s -X POST https://bank.example.com/soap -H 'Content-Type: application/soap+xml; charset=utf-8; action="GetBalance"' --data @get-balance.xml <soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"> <soap:Body> <acc:GetBalanceResponse xmlns:acc="http://bank.example.com/accounts"> <acc:Balance currency="INR">48250.00</acc:Balance> </acc:GetBalanceResponse> </soap:Body> </soap:Envelope>
Key terms
| Term | Simple meaning |
|---|---|
Envelope | The outer wrapper every message must have |
Header | Optional extras: auth, signatures, routing |
Body | The actual request or response |
Fault | The standard error format |
WSDL | The contract: operations, messages, endpoint |
WS-Security | Signing and encrypting the message itself |
GraphQL
One endpoint and a typed schema: the client writes a query for exactly the fields it needs, in one round trip.
Like a buffet where you fill one plate with exactly what you want, instead of ordering three fixed meals. One request can fetch a user, their last two orders and nothing else.
The server publishes a schema of types plus Query, Mutation and Subscription roots. Clients POST {query, variables} to /graphql, resolvers run per field, and the answer is {data, errors}, often HTTP 200 even with partial errors. It removes over and under fetching, but needs care: batch resolvers with DataLoader to avoid N+1 queries, cap query depth and cost, and use persisted queries since HTTP caching is harder. Subscriptions usually run over WebSocket.
Pick it forFrontends with many screens that each need a different slice of linked data.
- Transport
- HTTP POST, WebSocket for subscriptions
- Payload
- Query text in, JSON out
- Direction
- Request, then response
- Contract
- Schema, introspectable
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Query | query { user(id: 42) { name } } | Reads exactly the fields a screen needs | Pages, dashboards, mobile views |
Mutation | mutation { renameUser(id: 42, name: "A") { id name } } | Writes and returns the updated data in one round trip | Forms and user actions |
Subscription | subscription { orderUpdated(id: "o_981") { status } } | The server pushes changes, usually over WebSocket | Live status, chat, notifications |
Fragment | fragment UserCard on User { name avatar } | Reuses one field set across many queries | Component based UIs |
Variables | query($id: ID!) + {"id":"42"} | Keeps the query text fixed, safe from injection and cacheable | Always, instead of building strings |
Persisted query | {"extensions":{"persistedQuery":{"sha256Hash":"…"}}} | Sends a hash instead of the full text, enabling GET caching and allowlists | Public production clients and mobile apps |
Try it
query UserOrders($id: ID!) {
user(id: $id) {
name
orders(last: 2) {
id
total
}
}
}
The answer mirrors the query. Add or remove a field in the query and the JSON changes to match, without a new endpoint or a new API version.
# a recorded session, replayed when you press Run curl -s https://api.example.com/graphql -H "Content-Type: application/json" -d '{"query":"query($id:ID!){user(id:$id){name orders(last:2){id total}}}","variables":{"id":"42"}}' {"data":{"user":{"name":"Asha","orders":[{"id":"o_981","total":1499},{"id":"o_990","total":320}]}}}
Key terms
| Term | Simple meaning |
|---|---|
Schema | The typed menu of everything you can ask for |
Query | A read |
Mutation | A write |
Subscription | A live feed of changes |
Resolver | The function that fetches one field |
N+1 problem | One query per item in a list; fix with batching |
gRPC
Remote calls defined in .proto files, sent as compact Protocol Buffers over HTTP/2, with streaming in either direction.
Like calling a function that happens to live on another computer. Both sides share one contract file, and messages travel packed in a small binary form that is quick to send and quick to read.
Services and messages are written in the Protocol Buffers IDL; protoc generates client stubs and server interfaces in each language. Every call is an HTTP/2 stream: POST /package.Service/Method with content-type application/grpc, length prefixed protobuf messages, and the result in trailers (grpc-status). There are four call shapes: unary, server streaming, client streaming and bidirectional, plus deadlines, cancellation and metadata. Browsers cannot read HTTP/2 trailers, so web clients go through gRPC-Web or Connect.
Pick it forInternal service to service calls where speed, strict types and streaming matter.
- Transport
- HTTP/2
- Payload
- Protocol Buffers, binary
- Direction
- Unary or streaming, both ways
- Contract
- .proto, required
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Unary | rpc GetOrder (GetOrderRequest) returns (Order) | Plain request and response with strict types | Most calls between services |
Server streaming | rpc Track (TrackRequest) returns (stream TrackUpdate) | Many responses over one call, no polling | Progress, feeds, large result sets |
Client streaming | rpc Upload (stream Chunk) returns (Summary) | The client sends many messages and gets one answer | Uploads, batched metrics |
Bidirectional | rpc Chat (stream Msg) returns (stream Msg) | Both sides stream independently on one connection | Chat, real time sync |
Deadline | client.getOrder(req, { deadline: Date.now() + 2000 }, cb) | Bounds the wait and cancels work down the chain | Every production call |
gRPC-Web or Connect | Envoy proxy or @connectrpc/connect-web | Browsers cannot read HTTP/2 trailers, so they need a bridge | Calling gRPC services from a web app |
Try it
syntax = "proto3";
package orders.v1;
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc Track (TrackRequest) returns (stream TrackUpdate);
}
message GetOrderRequest { string id = 1; }
message TrackRequest { string id = 1; }
message Order { string id = 1; int64 total_paise = 2; string status = 3; }
message TrackUpdate { string status = 1; string at = 2; }
Field numbers are forever. The wire format uses the numbers, not the names. Never reuse or renumber a field; add new ones and mark old ones reserved.
# a recorded session, replayed when you press Run grpcurl -plaintext -d '{"id":"o_981"}' localhost:50051 orders.v1.OrderService/GetOrder { "id": "o_981", "totalPaise": "149900", "status": "PACKED" } grpcurl -plaintext -d '{"id":"o_981"}' localhost:50051 orders.v1.OrderService/Track { "status": "PACKED", "at": "2026-10-03T09:10:00Z" } { "status": "SHIPPED", "at": "2026-10-03T14:42:00Z" }
Key terms
| Call type | Shape | Example |
|---|---|---|
Unary | One request, one response | GetOrder |
Server streaming | One request, many responses | Track a delivery |
Client streaming | Many requests, one response | Upload sensor readings |
Bidirectional | Both sides stream at once | Live chat, game state |
tRPC
End to end type safety for TypeScript: the client imports the server's types, so there is no schema file and no code generation.
Like the frontend and backend sharing one dictionary. Rename a field on the server and your editor underlines every place in the frontend that uses it, before anything runs.
You define procedures (query, mutation, subscription) on a router with input validators such as Zod, then export only its type. The client is a typed proxy inferred from that type at compile time. On the wire it is plain HTTP and JSON: queries are GET /trpc/proc?input=…, mutations are POST, httpBatchLink merges calls made in the same tick, and subscriptions run over SSE or WebSocket. Both ends must be TypeScript sharing the types, usually in a monorepo.
Pick it forA TypeScript frontend and backend owned by the same team. Not for public or multi language APIs.
- Transport
- HTTP, SSE or WebSocket
- Payload
- JSON
- Direction
- Request, then response
- Contract
- TypeScript types, inferred
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
query | trpc.userById.query({ id: 42 }) | Sent as GET, so responses can be cached | Fetching data |
mutation | trpc.renameUser.mutate({ id: 42, name: "A" }) | Sent as POST for writes | Creating and updating |
subscription | .subscription(async function* () { yield … }) | Pushes over SSE (httpSubscriptionLink) or WebSocket | Live updates |
Input validation | .input(z.object({ id: z.number() })) | Checks data at runtime and infers the TypeScript type | Every procedure that takes input |
Middleware | t.procedure.use(isAuthed) | Shares auth, logging and context across procedures | Protected or audited procedures |
httpBatchLink | links: [httpBatchLink({ url })] | Merges calls made in the same tick into one request | Pages that fire several queries at once |
Try it
// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure
.input(z.object({ id: z.number() }))
.query(({ input }) => db.user.findById(input.id)),
});
export type AppRouter = typeof appRouter;
// client.ts
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "./server/router";
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "http://localhost:3000/trpc" })],
});
const user = await trpc.userById.query({ id: 42 }); // fully typed
Only the type crosses over. import type is erased at build time, so no server code ships to the browser.
# a recorded session, replayed when you press Run curl -s 'localhost:3000/trpc/userById?input=%7B%22id%22%3A42%7D' {"result":{"data":{"id":42,"name":"Asha"}}} # rename name to fullName on the server, then type check the client npx tsc --noEmit client.ts(21,18): error TS2339: Property 'name' does not exist on type '{ id: number; fullName: string; }'.
Key terms
| Term | Simple meaning |
|---|---|
Router | The tree of every procedure the server offers |
Procedure | One callable function: query, mutation or subscription |
Input validator | Checks the arguments at runtime, usually Zod |
Link | The client's transport, such as httpBatchLink |
Batching | Calls made together travel as one request |
Polling and long polling
Updates over plain HTTP: ask again on a timer, or ask once and let the server hold the answer until something happens.
Short polling is a child asking "are we there yet?" every five minutes. Long polling is asking once, and the driver only answers when you arrive, or after a while to say "not yet".
Short polling sends requests at a fixed interval, so latency is up to one interval and most answers are empty. Long polling keeps each request open until data arrives or a timeout (often 25 to 30 seconds, below proxy idle limits), then the client asks again at once with a cursor so no event is missed. It works through every proxy and firewall, but each waiting client holds a connection, and synchronized retries can create a thundering herd. RFC 6202 lists the trade offs.
Pick it forJob status checks, or a fallback when SSE and WebSocket are blocked.
- Transport
- Plain HTTP
- Payload
- Anything
- Direction
- Client asks repeatedly
- Latency
- Interval, or near instant when long
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Short polling | setInterval(() => fetch("/jobs/77"), 5000) | The simplest thing that works anywhere | Slow changing status: exports, reports |
Long polling | GET /events?after=105&wait=30 | Near instant updates over plain HTTP | When proxies block SSE or WebSocket |
Conditional polling | If-None-Match: "v7" → 304 | Unchanged answers cost almost nothing | Resources that rarely change |
Backoff | wait 1s, 2s, 4s … up to 30s | Cuts load while nothing is happening | Waiting on long jobs |
Retry-After | Retry-After: 10 | The server tells the client when to come back | Rate limits and busy periods |
Try it
# short polling: ask every 5 seconds
while true; do
curl -s https://api.example.com/jobs/77
sleep 5
done
# long polling: the server waits up to 30 s before answering
cursor=105
while true; do
resp=$(curl -s --max-time 35 "https://api.example.com/events?after=$cursor&wait=30")
[ -n "$resp" ] && echo "$resp" && cursor=$(echo "$resp" | jq '.[-1].id')
done
Add jitter to retries. If thousands of clients reconnect at the same second after a deploy, a random delay of a few hundred milliseconds spreads the load.
# a recorded session, replayed when you press Run bash poll.sh {"id":77,"status":"queued"} {"id":77,"status":"running"} {"id":77,"status":"done","url":"/reports/77.pdf"} # long polling: one line per real event, nothing in between [{"id":106,"type":"order.created"}] [{"id":107,"type":"order.paid"}]
Key terms
| Term | Simple meaning |
|---|---|
Interval | How long short polling waits between asks |
Timeout | How long the server holds a long poll open |
Cursor | Where you got up to, so nothing is skipped |
Thundering herd | Everyone retrying at the same moment |
Jitter | A small random delay that spreads retries |
Webhooks
A reverse API: instead of you asking, the other service calls your URL the moment something happens.
Like giving a shop your phone number so they call when the parcel arrives, instead of you phoning them every hour.
The provider POSTs an event as JSON to an HTTPS endpoint you registered. Verify every delivery: compute an HMAC SHA-256 of the raw body (plus a timestamp, to stop replays) with the shared secret and compare in constant time. Answer 2xx within a few seconds and do the real work from a queue. Providers retry with backoff when you fail, so the same event can arrive twice or out of order; store the event id and skip duplicates. The Standard Webhooks spec describes the headers.
Pick it forReacting to payments, git pushes, form submissions or any event in someone else's system.
- Transport
- HTTPS POST to you
- Payload
- JSON event
- Direction
- Provider calls you
- Trust
- HMAC signature header
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Event delivery | POST /webhooks {"type","id","data"} | Push instead of polling | Payments, git pushes, form submissions |
Signature check | HMAC-SHA256(secret, raw body) | Proves the sender and that nothing was altered | Every delivery |
Timestamp check | reject if older than 5 minutes | Stops an attacker replaying a captured message | Every delivery, with the signature |
Idempotent handling | skip if event.id already stored | Retries mean the same event can arrive twice | Every consumer |
Queue then 200 | queue.add(…); res.sendStatus(200) | Providers time out after a few seconds | Any real processing |
Thin events | {"type","id"} then GET /payments/:id | You always read fresh data, and payloads leak less | When order or staleness matters |
Try it
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.WEBHOOK_SECRET;
app.post("/webhooks/payments", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.get("X-Signature") ?? "";
const expected = crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
queue.add("payment", event, { jobId: event.id }); // same id, no duplicate job
res.sendStatus(200);
});
app.listen(3000);
Sign the raw bytes. Parse the JSON first and re-serialize it, and spacing or key order can change, so the signature never matches.
# a recorded session, replayed when you press Run body='{"id":"evt_301","type":"payment.captured","amount":149900}' sig=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | cut -d' ' -f2) curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/webhooks/payments -H "Content-Type: application/json" -H "X-Signature: $sig" -d "$body" 200 curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:3000/webhooks/payments -H "Content-Type: application/json" -H "X-Signature: forged" -d "$body" 401
Key terms
| Term | Simple meaning |
|---|---|
Endpoint | Your URL that receives the events |
Signature | Proof the message came from the provider |
Replay attack | Resending an old valid message; timestamps stop it |
Idempotency | Handling the same event twice changes nothing |
Backoff | Retries that wait longer each time |
Server-Sent Events
One HTTP response that never ends: the server keeps writing events, and the browser reconnects by itself.
Like a radio station. You tune in once and the server keeps broadcasting to you. You can listen, but you cannot talk back on the same channel; for that, send a normal request.
The client sends GET with Accept: text/event-stream; the server answers 200 with Content-Type: text/event-stream and keeps writing UTF-8 frames made of data:, event:, id: and retry: lines, each event ending with a blank line. The browser's EventSource reconnects on drop and sends Last-Event-ID so the server can resume. Text only and one direction. On HTTP/1.1 browsers allow about six connections per site, which HTTP/2 removes. Turn off proxy buffering. LLM token streaming uses SSE.
Pick it forLive feeds, notifications, dashboards and streaming AI responses.
- Transport
- HTTP, one long response
- Payload
- UTF-8 text events
- Direction
- Server to client only
- Reconnect
- Built in, with Last-Event-ID
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
EventSource | new EventSource("/stream") | Built into browsers, reconnects by itself | Browsers receiving updates |
Named events | event: price + addEventListener("price") | Routes each type to its own handler | Several kinds of event on one stream |
Resume by id | id: 42 → Last-Event-ID: 42 | No gaps after a reconnect | Feeds where missing an event matters |
retry | retry: 5000 | The server sets the reconnect delay | Shedding load during an incident |
fetch streaming | fetch(url, { method: "POST" }) + body.getReader() | EventSource cannot send a body or custom headers | AI chat completions with auth headers |
Heartbeat | : ping every 15 s | Keeps proxies from closing a quiet stream | Behind load balancers and CDNs |
Try it
// server.js, Node with no framework
import http from "node:http";
http.createServer((req, res) => {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
let id = Number(req.headers["last-event-id"] ?? 0);
const timer = setInterval(() => {
id += 1;
res.write(`id: ${id}\nevent: price\ndata: {"symbol":"NIFTY","ltp":${24800 + id}}\n\n`);
}, 1000);
req.on("close", () => clearInterval(timer));
}).listen(3000);
// browser
const es = new EventSource("/stream");
es.addEventListener("price", (e) => console.log(JSON.parse(e.data)));
Send a comment every 15 seconds. A line starting with a colon, such as : ping, keeps proxies from closing an idle stream and is ignored by EventSource.
# a recorded session, replayed when you press Run curl -N localhost:3000/stream id: 1 event: price data: {"symbol":"NIFTY","ltp":24801} id: 2 event: price data: {"symbol":"NIFTY","ltp":24802} # a new event every second until Ctrl+C
Key terms
| Line | Means |
|---|---|
data: … | The payload; several data lines join with newlines |
event: name | Which listener receives it; default is message |
id: 42 | Bookmark the browser sends back on reconnect |
retry: 5000 | Milliseconds to wait before reconnecting |
: comment | Ignored; useful as a keep alive |
WebSocket
Upgrade one HTTP connection into a two way pipe where either side can send a message at any moment.
Like a phone call instead of letters. Once connected, both people can talk whenever they like, without dialling again for every sentence.
It opens as an HTTP/1.1 GET with Upgrade: websocket and a random Sec-WebSocket-Key; the server replies 101 Switching Protocols with Sec-WebSocket-Accept, the base64 SHA-1 of the key plus a fixed GUID. After that the TCP connection carries frames: text or binary messages (masked from client to server), ping and pong heartbeats, and a close frame with a code. There is no built in reconnect, rooms, acknowledgements or backpressure; Socket.IO adds those. Scale out with sticky sessions and a pub/sub backplane such as Redis.
Pick it forChat, multiplayer, collaborative editing, trading screens: anything where the client also sends often.
- Transport
- TCP after an HTTP upgrade
- Payload
- Text or binary frames
- Direction
- Both ways, any time
- URLs
- ws:// · wss:// (TLS)
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Text frames | ws.send(JSON.stringify(msg)) | Readable and easy to debug | Chat, events, commands |
Binary frames | ws.send(arrayBuffer) | Compact, no text encoding cost | Audio, game state, protobuf |
Ping and pong | socket.ping() every 30 s | Finds dead connections that never closed | Long lived connections behind NAT |
Subprotocol | new WebSocket(url, ["graphql-transport-ws"]) | Both sides agree on a message format in the handshake | GraphQL subscriptions, STOMP |
Close codes | ws.close(1000, "bye") | Tells the peer why the connection ended | Clean shutdowns; 1001 during deploys |
Socket.IO | io.to("ops").emit("msg", data) | Adds rooms, acks and reconnects on top | Apps that need those out of the box |
Try it
// server.js (npm i ws)
import { WebSocketServer } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
const msg = JSON.parse(raw);
for (const client of wss.clients) {
if (client.readyState === 1) client.send(JSON.stringify({ from: "server", echo: msg }));
}
});
});
// browser
const ws = new WebSocket("ws://localhost:8080");
ws.onopen = () => ws.send(JSON.stringify({ type: "join", room: "ops" }));
ws.onmessage = (e) => console.log(JSON.parse(e.data));
Plan for reconnects yourself. A laptop lid closing gives close code 1006 with no frame. Reconnect with backoff and resend what the server missed.
# a recorded session, replayed when you press Run curl -si -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" localhost:8080 HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= npx wscat -c ws://localhost:8080 Connected (press CTRL+C to quit) > {"type":"join","room":"ops"} < {"from":"server","echo":{"type":"join","room":"ops"}}
Key terms
| Term or code | Simple meaning |
|---|---|
101 | The server agreed to switch to WebSocket |
Frame | One unit of data on the wire |
Ping / pong | Heartbeat that proves the other side is alive |
1000 | Normal close |
1006 | Dropped without a close frame |
Backplane | Pub/sub that lets many servers share messages |
MQTT
A tiny publish and subscribe protocol for devices on weak networks: publish to a topic, and every subscriber gets a copy.
Like a noticeboard in a building. A sensor pins a note under home/kitchen/temp, and everyone who asked to watch that heading gets a copy. Nobody needs to know who else is there.
A binary protocol over TCP (1883, 8883 with TLS) or WebSocket, with a fixed header as small as 2 bytes. Clients CONNECT to a broker such as Mosquitto, EMQX or HiveMQ with a keep alive, PUBLISH to slash separated topics and SUBSCRIBE with wildcards: + for one level, # for the rest. QoS 0 is at most once, 1 at least once (PUBACK), 2 exactly once (a four step handshake). Retained messages give newcomers the last value, and a Last Will is published if a client vanishes. MQTT 5 adds reason codes, shared subscriptions and message expiry.
Pick it forIoT sensors, phones on mobile data, vehicles: many small devices on unreliable links.
- Transport
- TCP, or WebSocket
- Payload
- Bytes, any format
- Direction
- Publish and subscribe
- Ports
- 1883 · 8883 (TLS)
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
QoS 0 | mosquitto_pub -q 0 | Fastest, no acknowledgement | Frequent telemetry where one lost reading is fine |
QoS 1 | mosquitto_pub -q 1 | Guaranteed delivery, duplicates possible | Most commands and events, with idempotent handlers |
QoS 2 | mosquitto_pub -q 2 | Exactly once through a four step handshake | Metering or billing where a duplicate costs money |
Retained | mosquitto_pub -r | New subscribers get the last value at once | Device state and config |
Last Will | --will-topic devices/pi-01/status | The broker announces a client that vanished | Presence and health |
Shared subscription | $share/workers/jobs/# | Spreads messages across a group of consumers | Scaling backend processors on MQTT 5 |
Try it
# terminal 1: watch every room's temperature
mosquitto_sub -h localhost -t "home/+/temp" -v
# terminal 2: publish a reading and keep it as the last known value
mosquitto_pub -h localhost -t "home/kitchen/temp" -m "24.6" -q 1 -r
# a Last Will: the broker announces it if this client disappears
mosquitto_sub -h localhost -t "home/#" --will-topic "devices/pi-01/status" --will-payload "offline" -v
Design topics like folders. Put the broad part first, site/building/room/metric, so wildcards can select whole branches.
# a recorded session, replayed when you press Run mosquitto_sub -h localhost -t "home/+/temp" -v home/kitchen/temp 24.6 home/bedroom/temp 22.1 # a reading published with -r reaches new subscribers at once home/kitchen/temp 24.9
Key terms
| QoS or term | Simple meaning |
|---|---|
QoS 0 | At most once: fire and forget |
QoS 1 | At least once: may duplicate |
QoS 2 | Exactly once: slowest |
+ and # | One level, or every level below |
Retained | The broker keeps the last message per topic |
Last Will | A message sent for you if you drop off |
AMQP and RabbitMQ
Reliable message queues: producers send to exchanges, routing rules fill queues, and consumers acknowledge every message.
Like a post office sorting room. You hand a letter to the counter (the exchange), sorting rules drop it into the right pigeonhole (the queue), and the worker who takes it signs for it. If the worker drops it, it goes back for someone else.
AMQP 0-9-1, RabbitMQ's main protocol, runs over TCP on 5672 (5671 with TLS) with many channels per connection. Exchanges route by type: direct (exact routing key), topic (patterns with * and #), fanout (every bound queue) and headers. Bindings connect exchanges to queues. Consumers ack, nack or requeue; prefetch caps unacknowledged messages per consumer; durable queues, persistent messages and publisher confirms give at least once delivery; dead letter exchanges catch failures. AMQP 1.0 is a different standard that RabbitMQ 4 also speaks.
Pick it forBackground jobs and work that must not be lost: emails, payments, image processing.
- Transport
- TCP
- Payload
- Bytes plus properties
- Direction
- Producer to queue to worker
- Ports
- 5672 · 5671 (TLS)
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
direct | bindQueue("emails", "tasks", "email") | Exact routing by key | Work queues per task type |
topic | "order.*" or "order.#" | Pattern routing on dotted keys | Domain events |
fanout | assertExchange("logs", "fanout") | Copies every message to every bound queue | Broadcasts, cache invalidation |
Publisher confirms | await conn.createConfirmChannel() | The broker acknowledges each message it stored | Messages you cannot afford to lose |
Dead letter exchange | { deadLetterExchange: "dlx" } | Catches rejected and expired messages | Retries and poison messages |
Prefetch | ch.prefetch(10) | Fair dispatch and bounded memory per worker | Every consumer |
Try it
import amqp from "amqplib";
const conn = await amqp.connect("amqp://localhost");
const ch = await conn.createChannel();
await ch.assertExchange("orders", "topic", { durable: true });
await ch.assertQueue("email-jobs", { durable: true });
await ch.bindQueue("email-jobs", "orders", "order.*");
// producer
ch.publish("orders", "order.created", Buffer.from(JSON.stringify({ id: "o_981" })), { persistent: true });
// consumer
await ch.prefetch(10);
await ch.consume("email-jobs", (msg) => {
console.log("send email for", JSON.parse(msg.content.toString()).id);
ch.ack(msg);
});
Ack after the work, not before. Acknowledge once the email is sent. If the worker crashes first, RabbitMQ redelivers the message to another worker.
# a recorded session, replayed when you press Run node orders.js send email for o_981 sudo rabbitmqctl list_queues name messages consumers Timeout: 60.0 seconds ... Listing queues for vhost / ... name messages consumers email-jobs 0 1
Key terms
| Exchange type | Routes to |
|---|---|
direct | Queues whose binding key equals the routing key |
topic | Pattern matches: order.* or order.# |
fanout | Every bound queue, key ignored |
headers | Matches on message header values |
Kafka protocol
A distributed commit log: producers append events to partitioned topics, and each consumer group reads at its own pace, as often as it likes.
Like a ledger that only gains new lines at the bottom. Any team can read it, keep its own bookmark and reread old pages later. Reading never erases anything.
Kafka uses its own binary request and response protocol over TCP, usually port 9092. Topics split into partitions, each an ordered append only log replicated to other brokers (leader plus in sync replicas). The producer picks a partition by hashing the key, so one key keeps its order; acks=all with the idempotent producer avoids duplicates. Consumers in a group share the partitions and commit offsets; joining or leaving triggers a rebalance. Data stays for a retention period or is compacted per key. Kafka 4 runs on KRaft without ZooKeeper.
Pick it forEvent driven systems, audit trails, analytics pipelines, and many consumers of the same events.
- Transport
- TCP, Kafka binary protocol
- Payload
- Key, value, headers
- Direction
- Append, then pull
- Port
- 9092
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
acks=0 | acks=0 | Fastest; the producer does not wait | Metrics where some loss is acceptable |
acks=all + idempotence | acks=all, enable.idempotence=true | No loss and no duplicates per partition | Orders, payments; the default since Kafka 3.0 |
Keyed partitioning | key = user42 | Same key, same partition, kept in order | Per entity event streams |
Consumer group | group.id = billing | Partitions are shared out so the group scales | Each service reading a topic |
Log compaction | cleanup.policy = compact | Keeps the latest event per key forever | Changelogs and state snapshots |
Transactions | transactional.id = billing-1 | Read, process and write atomically for exactly once | Stream processing pipelines |
Try it
# produce with a key: same key, same partition, same order
echo 'user42:{"type":"order.created","id":"o_981"}' | kcat -b localhost:9092 -t orders -K: -P
# consume as part of the billing group
kcat -b localhost:9092 -G billing orders
# read the topic like a file, with partition and offset
kcat -b localhost:9092 -t orders -C -o beginning -f '%p:%o %k %s\n' -e
# how far behind is each group?
kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing
Order is per partition, not per topic. Give related events the same key, such as the user id, so they land in one partition and stay in sequence.
# a recorded session, replayed when you press Run kcat -b localhost:9092 -t orders -C -o beginning -f '%p:%o %k %s\n' -e 0:1041 user42 {"type":"order.created","id":"o_981"} % Reached end of topic orders [0] at offset 1042: exiting kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group billing GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG billing orders 0 1042 1042 0
Key terms
| Term | Simple meaning |
|---|---|
Topic | A named stream of events |
Partition | One ordered slice of a topic |
Offset | An event's position, the bookmark |
Consumer group | Workers that share a topic's partitions |
Lag | How many events a group has not read yet |
Compaction | Keep only the latest event per key |
WebRTC
Real time audio, video and data directly between browsers, with servers only helping the two peers find each other.
Like two people swapping addresses through a mutual friend, then talking directly. The friend is the signaling server; a STUN server tells each person their public address; if the walls are too thick, a TURN server relays the call.
Each side creates an RTCPeerConnection and exchanges an SDP offer and answer over your own signaling channel, often a WebSocket. ICE gathers candidate addresses (host, server reflexive from STUN, relay from TURN) and tests pairs until one connects through the NATs. Media is always encrypted with DTLS-SRTP, and data channels run SCTP over DTLS, reliable or unordered. Calls with many people route through an SFU such as LiveKit or mediasoup instead of a full mesh.
Pick it forVideo calls, screen sharing, low latency game data and browser to browser file transfer.
- Transport
- UDP, falls back to TCP or TURN
- Payload
- Audio, video, data channels
- Direction
- Peer to peer, both ways
- Security
- Encryption is mandatory
How a conversation looks
requestresponsepush or streamcontrol
Methods, usage, why and when
| Method | Usage | Why | When to use |
|---|---|---|---|
Media tracks | pc.addTrack(track, stream) | Low latency audio and video with built in jitter handling | Calls and live streaming |
Reliable data channel | pc.createDataChannel("chat") | Ordered with retransmits, like TCP | Chat and file transfer |
Unordered data channel | createDataChannel("game", { ordered: false, maxRetransmits: 0 }) | The latest update wins and nothing stalls | Game state, live cursors |
STUN | { urls: "stun:stun.l.google.com:19302" } | Each peer learns its public address | Always |
TURN | { urls: "turn:turn.example.com:3478", username, credential } | Relays traffic when no direct path exists | Production, especially corporate networks |
SFU | LiveKit, mediasoup, Janus | Each peer uploads once and the server forwards | Group calls beyond about four people |
Try it
const pc = new RTCPeerConnection({
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
const chat = pc.createDataChannel("chat");
chat.onopen = () => chat.send("hello from A");
pc.onicecandidate = ({ candidate }) => candidate && signal.send({ candidate });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signal.send({ sdp: pc.localDescription }); // signal is your WebSocket
signal.onmessage = async ({ sdp, candidate }) => {
if (sdp) await pc.setRemoteDescription(sdp);
if (candidate) await pc.addIceCandidate(candidate);
};
Budget for TURN in production. Roughly one call in ten cannot connect directly behind strict corporate or mobile NATs, and only a TURN relay rescues it.
# a recorded session, replayed when you press Run # browser console on peer A candidate:842163049 1 udp 1677729535 203.0.113.7 54012 typ srflx raddr 192.168.1.20 rport 54012 connectionState: connecting connectionState: connected chat: hello from B
Key terms
| Term | Simple meaning |
|---|---|
SDP | A description of what each side can send and receive |
Signaling | How peers swap SDP; WebRTC leaves it to you |
ICE | The process of finding a path between peers |
STUN | Tells a peer its public address |
TURN | Relays traffic when no direct path exists |
SFU | A server that forwards streams in group calls |
Which one should I pick?
Read across the row for the job you have. When two look equal, pick the one your team and your clients already speak.
| Protocol | Transport | Payload | Direction | In a browser | Pick it when |
|---|---|---|---|---|---|
REST | HTTP | JSON | Ask and answer | Yes | Public and CRUD APIs |
SOAP | HTTP | XML | Ask and answer | Awkward | Enterprise systems with a WSDL |
GraphQL | HTTP | JSON | Ask and answer | Yes | Screens that need custom data shapes |
gRPC | HTTP/2 | Protobuf | Unary or streams | Via gRPC-Web | Fast typed service to service calls |
tRPC | HTTP | JSON | Ask and answer | Yes | One TypeScript team, front and back |
Long polling | HTTP | Any | Server, slowly | Yes | Fallback when push is blocked |
Webhooks | HTTP POST | JSON | Provider to you | No | Events from another system |
SSE | HTTP | Text | Server to client | Yes | Feeds, notifications, AI streaming |
WebSocket | TCP | Text or binary | Both ways | Yes | Chat, games, collaboration |
MQTT | TCP | Bytes | Pub/sub | Over WebSocket | IoT and weak networks |
AMQP | TCP | Bytes | Queue to worker | No | Jobs that must not be lost |
Kafka | TCP | Records | Append, then pull | No | Event streams many teams replay |
WebRTC | UDP | Media and data | Peer to peer | Yes | Calls and low latency data |