Communication Protocols in System Design
#http
#distributed-system
#grpc
#communication-protocols
#network-protocols
#data-transmission
The clearest way to see why communication protocols in system design matter is to watch what happens when one stops working. On October 4, 2021, Facebook, Instagram, WhatsApp and Messenger disappeared from the internet for about six hours. The servers were fine. The apps were fine. The problem was in the rules computers use to find each other.
According to Facebook's own engineering write-up, a command run during routine maintenance accidentally took down all the connections in its global backbone network, the private network linking its data centers. Facebook's DNS servers (the servers that tell the world which IP address belongs to facebook.com) are built to protect users: if they can't reach the data centers, they stop announcing themselves to the internet using a protocol called BGP. That's exactly what they did. So even though the DNS servers were still running, nobody on the internet could reach them. Phones and browsers asked "where is facebook.com?" and got no answer.
Two protocols most developers never think about, BGP and DNS, took down some of the largest apps in the world. Everything you build sits on top of protocols like these. When you choose between REST and gRPC, WebSockets and polling or a queue and a direct call, you're choosing protocols too and each choice decides how fast, reliable and secure your system is.
This guide explains communication protocols in plain English, starting from the basics. It covers how the layers fit together, TCP vs UDP, HTTP/1.1 vs HTTP/2 vs HTTP/3, REST vs GraphQL vs gRPC, JSON vs Protobuf, real-time options like WebSockets and Server-Sent Events, webhooks, message queues, MQTT and how to handle timeouts, retries and security. Every code example was run and the outputs shown are real.
Communication protocols in 60 seconds
- What: a protocol is an agreed set of rules for how two computers talk: the message format, the order of steps and what to do when something goes wrong.
- Layers: protocols stack. HTTP runs on TCP (or QUIC), which runs on IP. Each layer solves one problem.
- Transport: TCP is reliable and ordered. UDP is fast with no delivery promise. QUIC (under HTTP/3) gives reliability on top of UDP.
- APIs: REST is simple and everywhere. gRPC is fast and great between services. GraphQL lets clients ask for exactly the data they need.
- Real-time: use Server-Sent Events for server-to-client updates, WebSockets for two-way chat or games.
- Async: message queues and pub/sub (Kafka, RabbitMQ, MQTT) decouple services so they don't have to be online at the same time.
- Always: set timeouts, retry with backoff and jitter, make writes idempotent and encrypt with TLS.
What Is a Communication Protocol?
A communication protocol is a set of rules that two systems follow to exchange data. It defines how messages are formatted, in what order they're sent, how each side knows a message arrived and what happens if something fails.
Think about how you'd send something important across India.
- You could send it by registered post. You get a receipt, the post office tracks it and if it gets lost, you know. It's slower, but reliable.
- You could make an announcement on a loudspeaker. It's instant and everyone nearby hears it, but if someone missed it, nobody repeats it.
- You could have a phone call. Both people are connected the whole time and can talk back and forth.
- You could drop a letter in a company mailbox. The other person picks it up later, when they're free.
Computers have protocols that work just like each of these. Registered post is like TCP. The loudspeaker is like UDP. The phone call is like a WebSocket. The mailbox is like a message queue. Choosing a protocol is mostly about choosing which of these behaviors you need.
Key terms in plain English
| Term | Meaning |
|---|---|
| Client | The side that starts the conversation (a phone app, a browser, a service). |
| Server | The side that waits for requests and answers them. |
| Request / response | The client asks, the server answers. The most common pattern. |
| Latency | How long one message takes to get there and back. |
| Throughput | How much data can flow per second. |
| Handshake | A few setup messages before real data flows. |
| Connection | An ongoing "line" between two machines that stays open for multiple messages. |
| Serialization | Turning data in memory into bytes to send (and back again). JSON and Protobuf are formats for this. |
| Synchronous | The caller waits for the answer before moving on. |
| Asynchronous | The caller sends a message and moves on; the answer (if any) comes later. |
| Idempotent | Doing it twice has the same effect as doing it once. |
How Protocols Stack: The Layers, Simplified
Protocols are built in layers. Each layer does one job and trusts the layer below it to do its job. You'll hear about the 7-layer OSI model in textbooks, but the simpler TCP/IP model with four layers is closer to how the internet actually works:
┌──────────────────────────────────────────────────────────────────────┐
│ APPLICATION What the message means │
│ HTTP, gRPC, WebSocket, DNS, SMTP, MQTT, AMQP, Kafka │
├──────────────────────────────────────────────────────────────────────┤
│ TRANSPORT Getting data between two programs reliably (or not) │
│ TCP, UDP, QUIC │
├──────────────────────────────────────────────────────────────────────┤
│ INTERNET Finding a path across networks to the right machine │
│ IP (IPv4, IPv6). Routes between networks are chosen by │
│ BGP, a routing protocol that itself runs over TCP. │
├──────────────────────────────────────────────────────────────────────┤
│ LINK Moving bits over the actual wire, fiber or Wi-Fi │
│ Ethernet, Wi-Fi │
└──────────────────────────────────────────────────────────────────────┘
When your app calls an API, the request travels down these layers on your machine, across the network and back up on the server. TLS (the encryption behind HTTPS) sits between the application and transport layers, wrapping the data so nobody in between can read or change it.
Real protocols don't always fit neatly into one layer. BGP is a good example: its job is to steer IP traffic between networks, but BGP routers exchange their messages over TCP connections. The layer model is a thinking tool, not a strict rulebook.
In system design, you mostly make choices at the top two layers: which transport (TCP, UDP or QUIC) and which application protocol (HTTP-based APIs, WebSockets, queues and so on). The lower layers usually come from your cloud provider and the internet itself, but as the Facebook outage showed, they can still take you down.
TCP vs UDP: The Two Basic Transport Protocols
1. TCP: The reliable one
TCP (Transmission Control Protocol) delivers data reliably and in order. If a piece gets lost, TCP resends it. If pieces arrive out of order, TCP puts them back in order before your program sees them.
Before sending data, TCP does a three-way handshake: the client says "hello" (SYN), the server replies (SYN-ACK) and the client confirms (ACK). That costs one round trip before any real data flows.
Client Server
│ ──── SYN ────────────────► │
│ ◄─── SYN-ACK ───────────── │ 1 round trip of setup
│ ──── ACK + data ─────────► │
│ ◄─── data ──────────────── │
TCP also controls speed. It slows down when the network looks congested, so it doesn't make things worse for everyone. Most of the internet runs on TCP: web pages, APIs, databases, email.
One thing beginners often miss: TCP is a stream of bytes, not a stream of messages. If you send "hello" and then "world," the other side might read "helloworld" in one go or "hel" and then "loworld." Application protocols on top of TCP (like HTTP) add their own rules for where one message ends and the next begins. This is called framing.
2. UDP: The fast one
UDP (User Datagram Protocol) sends individual packets with no handshake, no delivery guarantee and no ordering. It's fast and simple and it's up to the application to deal with loss.
That sounds worse, but it's exactly right for some jobs:
- Live video calls and online games: a late packet is useless. If a voice packet from 200 ms ago arrives now, playing it would only make things worse. Better to skip it.
- DNS lookups: one small question, one small answer. A handshake would double the time. If the answer doesn't come, just ask again. (DNS also uses TCP when a response is too large for UDP and for copying whole zones between servers and newer options like DNS over HTTPS and DNS over TLS run over encrypted connections.)
- Building new protocols: QUIC is built on UDP so it can add its own smarter reliability.
TCP vs UDP comparison
| TCP | UDP | |
|---|---|---|
| Connection setup | Handshake first | None, just send |
| Delivery | Guaranteed (resends lost data) | Not guaranteed |
| Order | Kept in order | Can arrive out of order |
| Speed | Slower to start, steady after | Very low overhead |
| Message boundaries | No (it's a byte stream) | Yes (each packet is one message) |
| Typical uses | Web, APIs, databases, file transfer | Video calls, games, DNS, QUIC |
3. QUIC: Reliability on top of UDP
QUIC is a newer transport protocol that runs on top of UDP but gives you TCP-like reliability, built-in encryption and faster connection setup. It started at Google and was standardized by the IETF as RFC 9000 in 2021. It's the transport underneath HTTP/3.
Why build on UDP instead of fixing TCP? TCP lives inside operating systems and network equipment all over the world, so changing it takes many years. UDP is a blank canvas: QUIC can be updated in the app or browser itself.
What QUIC improves:
- Faster setup: QUIC combines the connection handshake and the TLS encryption handshake. A new connection usually needs one round trip before data flows and reconnecting to a server it has seen before can send data immediately ("0-RTT," with some security trade-offs).
- No cross-stream blocking: if one packet is lost, only the request it belongs to waits. Other requests on the same connection keep going (more on this below).
- Connection migration: when your phone switches from Wi-Fi to mobile data, a QUIC connection can survive the change of IP address, instead of starting over.
HTTP/1.1 vs HTTP/2 vs HTTP/3
HTTP is the protocol of the web and of most APIs. It's had three major versions and each one fixed the biggest problem of the previous one.
1. HTTP/1.1: one request at a time per connection
With HTTP/1.1, a connection handles one request at a time. If a page needs 50 files, the browser either waits for each file before asking for the next or opens several connections in parallel. Browsers typically open only about six connections per host, so the rest wait in line.
This waiting is called head-of-line blocking: one slow response holds up everything behind it, like a single slow customer at the only open billing counter.
2. HTTP/2: many requests on one connection
HTTP/2 (first published in 2015, now RFC 9113) splits each request and response into small frames and lets many of them share one connection at the same time. This is called multiplexing. It also compresses headers, which helps because headers like cookies repeat on every request.
But HTTP/2 still runs on TCP and TCP delivers bytes in order. If one TCP packet is lost, every stream on that connection waits until it's resent, even streams whose data already arrived. So HTTP/2 fixed head-of-line blocking at the HTTP level but not at the TCP level. On a clean network that rarely matters. On a lossy mobile network, it can.
3. HTTP/3: HTTP over QUIC
HTTP/3 (RFC 9114, 2022) keeps HTTP's meaning (methods, headers, status codes) but runs on QUIC instead of TCP. Since QUIC handles each stream separately, a lost packet only delays the one stream it belongs to.
HTTP/1.1 HTTP/2 HTTP/3
one request many streams, one TCP conn many streams, one QUIC conn
at a time ┌ stream 1 ───┐ ┌ stream 1 ───┐
per connection ├ stream 2 ───┤ one lost ├ stream 2 ─X─┤ only stream 2
└ stream 3 ───┘ packet blocks └ stream 3 ───┘ waits for the
ALL streams resend
| HTTP/1.1 | HTTP/2 | HTTP/3 | |
|---|---|---|---|
| Transport | TCP | TCP | QUIC (on UDP) |
| Requests per connection | One at a time | Many at once | Many at once |
| Header format | Plain text | Compressed binary | Compressed binary |
| Lost packet blocks | That connection | All streams on the connection | Only the affected stream |
| Encryption | Optional (HTTPS separate) | Browsers only use it with TLS; cleartext HTTP/2 ("h2c") exists and is sometimes used for internal traffic, such as gRPC behind a proxy | Always (built into QUIC) |
| Best for | Simple tools, debugging, old clients | Most APIs and websites today | Mobile and lossy networks, global users |
You usually don't pick HTTP versions in your application code. Your load balancer, CDN and client libraries negotiate the best version both sides support. What you do choose is whether to enable HTTP/2 and HTTP/3 on your edge and whether your internal services (for example, gRPC) need HTTP/2.
Real-world trade-off: the HTTP/2 Rapid Reset attack (2023)
Multiplexing is HTTP/2's best feature and in 2023 attackers turned it into a weapon. In a coordinated disclosure, Google, Amazon and Cloudflare described an attack called Rapid Reset (tracked as CVE-2023-44487). The attacker opens a stream, immediately cancels it with a reset frame and repeats, very fast. The server does the work of starting each request, but the attacker's cost is tiny and because the streams are cancelled, normal limits on how many streams can be open at once don't help.
The numbers were record-breaking: Google reported a peak above 398 million requests per second, Cloudflare above 201 million and Amazon about 155 million (Google, Cloudflare). Web servers and proxies shipped patches that track and limit excessive stream resets.
The takeaway for system designers: every protocol feature has a cost model. A feature that saves honest clients work can let dishonest clients create work. Keep your edge software (load balancers, proxies, web servers) patched and prefer managed edges that track these issues for you.
HTTPS and TLS: Keeping Communication Private
TLS (Transport Layer Security) encrypts data between two machines so nobody in between can read or change it. HTTPS is simply HTTP sent over TLS.
TLS gives you three things:
- Privacy: data is encrypted.
- Integrity: if someone changes the data on the way, the receiver can tell.
- Identity: the server proves who it is with a certificate, so you're not talking to an impostor.
TLS 1.3, the current version, needs one round trip to set up a new connection (older versions needed two). That's one reason connection reuse matters so much: reusing an open, already-encrypted connection skips all of that setup.
Inside your own systems, services increasingly use mutual TLS (mTLS), where both sides show certificates. That way, the payment service can prove it's really talking to the order service, not something pretending to be it. Service meshes like Istio and Linkerd can handle mTLS automatically between services.
A good default today: encrypt everything, including traffic inside your own network. "It's internal, so it's safe" is an assumption many breaches have proven wrong.
API Styles: REST vs GraphQL vs gRPC
Most systems talk through APIs built on HTTP. Three styles dominate. Strictly speaking, REST is an architectural style rather than a protocol, but in system design discussions, all three are compared side by side.
1. REST
REST (Representational State Transfer) organizes an API around resources, like /orders/9812 and uses standard HTTP methods to act on them: GET to read, POST to create, PUT or PATCH to update, DELETE to remove. The idea comes from Roy Fielding's 2000 doctoral dissertation.
GET /orders/9812 → read one order
POST /orders → create an order
PATCH /orders/9812 → change part of an order
DELETE /orders/9812/items/3 → remove an item
Why people like it: it's simple, it works everywhere (any language, any browser, curl), responses can be cached by browsers and CDNs and HTTP status codes (200, 404, 429, 503) already mean something to every tool.
Where it struggles: a screen might need data from five resources, so the app makes five calls (under-fetching) or one endpoint returns far more data than a small screen needs (over-fetching). Text formats like JSON are also bigger and slower to process than binary formats.
2. GraphQL
GraphQL is a query language for APIs. The client sends one request describing exactly which fields it wants and the server returns exactly that shape. It was developed at Facebook in 2012 and open-sourced in 2015.
query {
order(id: "9812") {
status
total
items { name quantity }
restaurant { name phone }
}
}
One request replaces several REST calls and the client gets only the fields it asked for. That's great for mobile apps on slow networks and for teams where the frontend changes faster than the backend.
The costs: caching is harder (requests are usually POSTs to a single /graphql endpoint, so a CDN can't cache them by URL without extra setup); a single clever query can ask for a huge amount of data, so servers need query depth and cost limits; and naive resolvers can cause the N+1 problem, where fetching 50 orders triggers 50 separate database calls for their restaurants. Tools like DataLoader batch those calls.
3. gRPC
gRPC is a high-performance remote procedure call (RPC) framework. You define services and messages in a .proto file and gRPC generates client and server code in many languages. It sends compact binary Protocol Buffers messages over HTTP/2. Google open-sourced it in 2015.
syntax = "proto3";
service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order);
rpc WatchOrder (GetOrderRequest) returns (stream OrderStatus); // server streaming
}
message GetOrderRequest { int64 order_id = 1; }
message Order {
int64 order_id = 1;
string user_id = 2;
int64 amount_paise = 3;
}
message OrderStatus { string status = 1; }
Calling a remote service then looks like calling a local function: orderClient.getOrder(request). gRPC supports four kinds of calls: one request and one response (unary), server streaming, client streaming and two-way (bidirectional) streaming.
Why teams choose it: small, fast messages; a strict contract checked at compile time; generated clients in many languages; built-in streaming and deadlines. It's especially popular for service-to-service traffic inside a company.
The costs: browsers can't call gRPC directly (you need gRPC-Web and a proxy); binary messages are harder to inspect by eye than JSON; and HTTP/2 connections are long-lived, so basic connection-level load balancing can send everything to one server unless you balance per request (for example, with Envoy or client-side load balancing).
Real-world example: LinkedIn's move from JSON to Protobuf, then to gRPC
LinkedIn ran its services on its own REST framework, Rest.li, with JSON as the format. In 2023, LinkedIn's engineering team described switching the encoding from JSON to Protocol Buffers while keeping the REST framework. They reported up to 60% lower latency for services with large payloads and about 8% better resource utilization. In a later interview with InfoQ, LinkedIn engineers explained that they were moving from Rest.li to gRPC, citing reasons such as gRPC's broad multi-language support.
What makes this example useful is how it splits two decisions people often mix up: the API style (REST vs gRPC) and the data format (JSON vs Protobuf). LinkedIn got a big win just by changing the format, before changing the API style.
REST vs GraphQL vs gRPC comparison
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Shape | Many endpoints, one per resource | One endpoint, client-defined queries | Service methods defined in .proto |
| Usual format | JSON | JSON | Protocol Buffers (binary) |
| Transport | HTTP/1.1, HTTP/2, HTTP/3 | Usually HTTP | HTTP/2 |
| Contract | Optional (OpenAPI) | Required schema | Required .proto |
| Caching | Easy with HTTP caching and CDNs | Harder | Not HTTP-cacheable in the usual way |
| Streaming | Not built in (use SSE or WebSockets) | Subscriptions (often over WebSockets) | Built in, all directions |
| Browser support | Native | Native | Needs gRPC-Web + proxy |
| Debugging | Easy (curl, readable JSON) |
Easy | Needs tools like grpcurl |
| Best fit | Public APIs, simple CRUD, cacheable reads | Varied UIs, mobile apps, many data sources | Internal microservices, high throughput, streaming |
There's no universal winner. A very common setup is: REST or GraphQL at the edge for browsers, mobile apps and partners and gRPC inside between services.
Data Formats: JSON vs Protocol Buffers
The protocol decides how messages travel. The format decides what the bytes look like. The two most common choices are JSON and Protocol Buffers.
JSON is text. It's readable, works in every language and needs no schema. But every message repeats its field names ("order_id", "user_id") and numbers are written as text characters.
Protocol Buffers (Protobuf) is binary. Field names aren't sent at all; each field is identified by a small number from the schema. Numbers use varints, a trick where small numbers take fewer bytes.
To see the difference, the program below encodes the same order both ways. The Protobuf encoder is written by hand from the official wire format rules, so you can see exactly what goes on the wire.
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
// Same order encoded two ways: JSON text vs Protocol Buffers binary wire format (hand-written encoder).
//
// message Order {
// int64 order_id = 1;
// string user_id = 2;
// int64 amount_paise = 3;
// Status status = 4; // enum: PLACED = 1
// repeated string sku = 5;
// }
public class WireFormat {
static void varint(ByteArrayOutputStream out, long v) {
while ((v & ~0x7FL) != 0) { // 7 bits per byte, high bit means "more bytes follow"
out.write((int) ((v & 0x7F) | 0x80));
v >>>= 7;
}
out.write((int) v);
}
static void tag(ByteArrayOutputStream out, int field, int wireType) {
varint(out, ((long) field << 3) | wireType); // wire type 0 = varint, 2 = length-delimited
}
static void intField(ByteArrayOutputStream out, int field, long value) {
tag(out, field, 0);
varint(out, value);
}
static void stringField(ByteArrayOutputStream out, int field, String s) {
byte[] b = s.getBytes(StandardCharsets.UTF_8);
tag(out, field, 2);
varint(out, b.length);
out.write(b, 0, b.length);
}
public static void main(String[] args) {
// Sanity check against the example in the Protobuf docs: field 1 = 150 encodes as 08 96 01.
ByteArrayOutputStream check = new ByteArrayOutputStream();
intField(check, 1, 150);
System.out.println("field 1 = 150 -> " + HexFormat.ofDelimiter(" ").formatHex(check.toByteArray()));
long orderId = 9_812_345_678L;
String userId = "u_48213";
long amountPaise = 149_900;
List<String> skus = List.of("SHOE-RED-42", "SOCK-BLK-M");
String json = "{\"order_id\":" + orderId + ",\"user_id\":\"" + userId + "\",\"amount_paise\":" + amountPaise
+ ",\"status\":\"PLACED\",\"sku\":[\"" + skus.get(0) + "\",\"" + skus.get(1) + "\"]}";
ByteArrayOutputStream pb = new ByteArrayOutputStream();
intField(pb, 1, orderId);
stringField(pb, 2, userId);
intField(pb, 3, amountPaise);
intField(pb, 4, 1); // PLACED
for (String s : skus) stringField(pb, 5, s);
System.out.println(json);
System.out.println("JSON bytes: " + json.getBytes(StandardCharsets.UTF_8).length);
System.out.println("Protobuf bytes: " + pb.size());
System.out.println("Protobuf hex: " + HexFormat.ofDelimiter(" ").formatHex(pb.toByteArray()));
}
}
Output:
field 1 = 150 -> 08 96 01
{"order_id":9812345678,"user_id":"u_48213","amount_paise":149900,"status":"PLACED","sku":["SHOE-RED-42","SOCK-BLK-M"]}
JSON bytes: 118
Protobuf bytes: 46
Protobuf hex: 08 ce 86 f2 c6 24 12 07 75 5f 34 38 32 31 33 18 8c 93 09 20 01 2a 0b 53 48 4f 45 2d 52 45 44 2d 34 32 2a 0a 53 4f 43 4b 2d 42 4c 4b 2d 4d
The first line matches the example in the Protobuf documentation. As an extra check, the same order was encoded with Google's official Protobuf library and it produced the exact same 46 bytes.
For this order, the Protobuf version is about 60% smaller (46 bytes vs 118). Notice where the savings came from: no field names, a 10-digit order ID packed into 5 bytes and the status sent as the number 1 instead of the word "PLACED". The strings themselves (u_48213, the SKUs) cost about the same in both formats, so messages full of long text shrink less. With gzip compression, JSON gets closer in size, but it still costs CPU to compress and to parse text.
| JSON | Protocol Buffers | |
|---|---|---|
| Readable by humans | Yes | No (needs tools) |
| Schema | Optional | Required (.proto) |
| Size | Larger | Smaller |
| Parsing speed | Slower (text) | Faster (binary) |
| Adding fields safely | Easy, but no checks | Easy, with rules (never reuse field numbers) |
| Browser support | Native | Needs a library |
| Best for | Public APIs, debugging, config | High-volume internal traffic, mobile data savings |
Schema evolution: changing messages without breaking things
Services are deployed at different times, so for a while, old and new versions talk to each other. Your message format has to survive that. Two words describe the goal:
- Backward compatible: new code can read messages written by old code.
- Forward compatible: old code can read messages written by new code (it just ignores what it doesn't understand).
Rules of thumb that keep you safe:
- Add fields, don't change them. New fields should be optional, with sensible defaults when missing.
- In Protobuf, never reuse or change a field number. When you delete a field, mark its number (and name) as
reservedso nobody reuses it by accident. Old messages with that number would otherwise be read as the wrong field. - Don't change a field's type (for example, from a number to a string). Add a new field instead.
- In JSON APIs, be a tolerant reader: ignore unknown fields instead of failing and never remove or rename a field that clients depend on without a versioning plan.
- With Avro and Kafka, a schema registry can check every new schema against compatibility rules before producers are allowed to use it.
Other formats you'll meet: Avro (popular with Kafka, schema stored with the data or in a registry), MessagePack (binary JSON-like, no schema) and Thrift (similar to Protobuf, from Facebook).
Real-Time Communication: Polling, Long Polling, SSE and WebSockets
Normal HTTP is "ask and answer." But many features need the server to tell the client when something changes: a delivery driver's location, a new chat message, a live score. There are four common ways to do it.
1. Short polling
The client asks every few seconds: "Anything new?" Usually the answer is "no."
It's simple, works everywhere and is fine for things that change slowly. But it wastes requests and adds delay: with a 5-second interval, an update can wait up to 5 seconds. And at scale, a million users polling every 5 seconds means 200,000 requests per second, mostly asking for nothing.
2. Long polling
The client asks and the server holds the request open until there's news (or a timeout, say 30 seconds). Then the client immediately asks again. Updates arrive almost instantly, with far fewer empty responses. It works over plain HTTP, which made it popular before WebSockets were widely supported. The downside is that each waiting client ties up a connection on the server and the constant reconnects add overhead.
3. Server-Sent Events (SSE)
Server-Sent Events let a server push a stream of text events to the client over one long-lived HTTP response. Browsers support it natively through the EventSource API, which also reconnects automatically and can tell the server the last event it received (with the Last-Event-ID header), so the server can resume.
SSE is one-way: server to client. That's enough for a surprising number of features: order tracking, notifications, live dashboards, progress bars and streaming responses from AI chat tools.
The Java program below is a tiny SSE server that pushes order status updates, plus a client that prints each event as it arrives:
import com.sun.net.httpserver.HttpServer;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
// Server-Sent Events: one long HTTP response, the server pushes events down it as they happen.
public class SseDemo {
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0);
server.createContext("/orders/9812/status", ex -> {
ex.getResponseHeaders().set("Content-Type", "text/event-stream");
ex.getResponseHeaders().set("Cache-Control", "no-cache");
ex.sendResponseHeaders(200, 0); // 0 = streamed body, length unknown
try (Writer w = new OutputStreamWriter(ex.getResponseBody(), StandardCharsets.UTF_8)) {
String[] steps = {"PLACED", "PACKED", "OUT_FOR_DELIVERY"};
for (int i = 0; i < steps.length; i++) {
w.write("id: " + (i + 1) + "\nevent: status\ndata: " + steps[i] + "\n\n"); // blank line ends an event
w.flush();
Thread.sleep(300); // pretend time passes between updates
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
server.start();
HttpClient client = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder(URI.create("http://localhost:8081/orders/9812/status"))
.header("Accept", "text/event-stream").build();
long start = System.currentTimeMillis();
client.send(req, HttpResponse.BodyHandlers.ofLines()).body()
.filter(line -> line.startsWith("data: "))
.forEach(line -> System.out.printf("+%4d ms %s%n", System.currentTimeMillis() - start, line));
server.stop(0);
}
}
Output from one run (exact timings vary by machine):
+ 265 ms data: PLACED
+ 532 ms data: PACKED
+ 834 ms data: OUT_FOR_DELIVERY
The client made one request and received three updates as they happened, roughly 300 ms apart. With short polling, it would have needed a request for every check. One limit to know: over HTTP/1.1, browsers cap open connections per domain (around six) and each SSE stream uses one. Over HTTP/2, many streams share one connection, so this limit mostly goes away.
4. WebSockets
A WebSocket is a long-lived, two-way connection between a client and a server. Either side can send a message at any time. The protocol (RFC 6455, 2011) starts as a normal HTTP request asking to "upgrade," then switches to its own lightweight message frames on the same TCP connection.
WebSockets fit features where both sides talk often: chat, multiplayer games, collaborative editing, live trading screens. Discord's real-time "Gateway," for example, is documented as a WebSocket connection that clients keep open to receive events like new messages.
What makes WebSockets harder in production:
- Stateful connections. Each server holds thousands of open connections. Scaling out means you need a way to send a message to a user connected to a different server, usually through a pub/sub layer like Redis or Kafka.
- Load balancers and deploys. Connections can last hours. Deploying new server versions means draining connections gracefully and clients must reconnect with backoff.
- You rebuild some HTTP features yourself: authentication on connect, message acknowledgements, retries and ordering are now your job.
- Proxies and firewalls occasionally interfere, so you need heartbeats (ping/pong) to detect dead connections.
WebRTC (briefly)
For video and voice calls, browsers use WebRTC, which can send media directly between users (peer-to-peer), typically over UDP, with helper servers (STUN and TURN) to get through home routers and firewalls. You'd use it for calling features, not for regular app data.
Choosing a real-time option
| Short polling | Long polling | SSE | WebSocket | |
|---|---|---|---|---|
| Direction | Client asks | Client asks, server waits | Server → client | Both ways |
| Delay for updates | Up to the poll interval | Near instant | Near instant | Near instant |
| Server load | Many wasted requests | Open connections + reconnects | One open connection per client | One open connection per client |
| Works over plain HTTP | Yes | Yes | Yes | Starts with HTTP, then upgrades |
| Auto-reconnect | Not needed | Manual | Built into browsers | Manual |
| Complexity | Lowest | Low | Low | Highest |
| Good for | Slow-changing data | Legacy real-time | Notifications, tracking, dashboards, streaming text | Chat, games, collaboration |
A practical rule: if only the server needs to push, start with SSE. Reach for WebSockets when the client also needs to send frequent messages over the same channel.
Webhooks: Server-to-Server Notifications
A webhook is an HTTP request that one system sends to another when an event happens. Instead of your server asking Stripe "has this payment finished yet?" every few seconds, Stripe calls a URL on your server when it does.
Webhooks are everywhere: payment providers (payment succeeded), GitHub (code pushed), shipping partners (package delivered), messaging platforms (message received).
Because a webhook is just an HTTP request arriving at a public URL, anyone could send a fake one. That's why providers sign them. GitHub, for example, documents a header called X-Hub-Signature-256 that contains an HMAC-SHA256 signature of the request body, made with a secret only you and GitHub know. Your server recomputes the signature and compares.
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
// Verify a webhook the way GitHub documents it: header "X-Hub-Signature-256: sha256=<hex HMAC>".
public class WebhookVerify {
static boolean isValid(String secret, byte[] rawBody, String signatureHeader) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
// Constant-time comparison, so attackers can't learn the signature from response timing.
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
signatureHeader.getBytes(StandardCharsets.UTF_8));
}
public static void main(String[] args) throws Exception {
String secret = "It's a Secret to Everybody"; // GitHub's documented test values
byte[] body = "Hello, World!".getBytes(StandardCharsets.UTF_8);
String header = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
System.out.println("genuine delivery: " + isValid(secret, body, header));
System.out.println("tampered body: " + isValid(secret, "Hello, World?".getBytes(StandardCharsets.UTF_8), header));
System.out.println("wrong secret: " + isValid("guess", body, header));
}
}
Output:
genuine delivery: true
tampered body: false
wrong secret: false
The secret, body and signature in this program are the test values from GitHub's documentation and the code reproduces GitHub's expected signature exactly.
Webhook rules that save you from real bugs:
- Verify the signature on the raw body bytes, before parsing JSON. Re-serializing the JSON can change spacing or key order and break the check.
- Respond quickly (with
200) and do the real work in the background. Providers time out and retry slow endpoints. - Expect duplicates and out-of-order delivery. Providers retry on failure, so the same event can arrive twice. Store the event ID and ignore repeats (make your handler idempotent).
- Have a way to catch up. If your endpoint was down for an hour, use the provider's API or event log to fetch what you missed.
Synchronous vs Asynchronous Communication
Every interaction between two services is either synchronous (the caller waits) or asynchronous (the caller doesn't). This one decision shapes latency, reliability and how failures spread, so it deserves its own section.
What is synchronous communication?
Synchronous communication means one service sends a request and waits for the response before it continues. The caller and the receiver both have to be up and working at the same moment.
It comes in three common forms:
- HTTP request-response: REST APIs, GraphQL and the older SOAP style. SOAP sends XML messages with a strict contract (described in a WSDL file) and is still common in long-running integrations in banking, insurance, telecom and government systems.
- RPC (remote procedure call): frameworks like gRPC make a remote call look like a local function call.
- Request-reply over a message broker: the caller sends a message and waits for a reply message. The transport is a queue, but the caller still waits, so the interaction is synchronous from the caller's point of view (more on this below).
Example: at checkout, the order service asks the payment service to authorize a card and waits for "approved" or "declined" before showing the result to the user.
Where synchronous communication fits
Use it when the next step genuinely can't happen without the answer:
- Login and access checks: the app must know the password or token is valid before showing private data.
- Payment authorization: the user needs to know right away whether the card was approved. Note that the money usually moves later: card payments are typically authorized immediately and captured or settled afterwards, which is a natural fit for asynchronous processing.
- Reads the user is waiting on: loading a profile, running a search, fetching a product page.
- Validation before committing: "is this coupon valid?", "is this item still in stock?"
- Database queries and transactions: the application waits for the result or the commit confirmation.
The hidden costs of synchronous calls
Synchronous calls are easy to write and easy to debug, because the flow reads top to bottom. The costs show up at scale:
- Latency adds up. If a request calls three services one after another, the user waits for all three. Calling independent services in parallel helps.
- Availability multiplies. If a request depends on five services in a chain and each is available 99.9% of the time, the request succeeds only about 99.5% of the time (0.999 multiplied by itself five times). That's roughly 44 hours of failed requests a year instead of about 9. This math assumes the five services fail independently. In real systems they often share things, like a database, a network or a cloud region, so failures tend to cluster. That can make the real number better (they fail at the same moments) or worse (one shared failure takes all five down). Either way, every synchronous dependency you add lowers your ceiling.
- Waiting uses resources. In a thread-per-request server, every request waiting on a slow dependency holds a thread. If one dependency slows down, threads pile up until the whole service stops responding, even for requests that never touch that dependency.
- Failures cascade. A slow service makes its callers slow, which makes their callers slow. Timeouts, circuit breakers (covered in the reliability section) and bulkheads (separate thread or connection pools per dependency, so one slow dependency can't use up everything) keep a local problem local.
Async I/O is not the same as asynchronous communication
This is a common source of confusion. Making a call non-blocking in your code (for example, with CompletableFuture, reactive clients or Node.js promises) frees the thread while you wait. That improves resource usage. But the other service still has to be up and answer right now. If it's down, the call still fails.
Asynchronous communication is different: you hand the message to a durable broker and the receiver can process it later, even if it's down at the moment you send. The difference is temporal coupling, meaning whether both sides must be available at the same time.
| Blocking sync call | Non-blocking (async I/O) call | Asynchronous messaging | |
|---|---|---|---|
| Caller's thread while waiting | Blocked | Free | Free |
| Receiver must be up right now | Yes | Yes | No |
| Caller gets the result | Immediately | Immediately (via callback or future) | Later or never directly |
What is asynchronous communication?
Asynchronous communication means a service sends a message and continues without waiting for it to be processed. The message is stored by a broker (a queue, topic or event stream) and one or more consumers process it when they're ready.
Depending on the setup, a message is handled by one consumer (a queue spreading work across workers) or delivered to many subscribers (pub/sub, where each interested service gets its own copy).
Example: after an order is placed, the order service publishes "OrderPlaced." The notification, loyalty and analytics services each process it on their own schedule. If the notification service is down for ten minutes, orders keep flowing and the emails go out when it recovers.
What it buys you:
- Fault isolation: a consumer being down doesn't stop the producer.
- Load leveling: a sudden spike (a flash sale, a viral post) piles up in the queue instead of crushing downstream services, which work through it at a steady pace.
- Independent scaling: add more consumers when the backlog grows, without touching the producer.
- Easy fan-out: new services can subscribe to existing events without changing the service that publishes them.
What it costs:
- More moving parts: a broker to run and monitor, plus retries, dead-letter queues and duplicate handling.
- Eventual consistency: for a while, different services hold different versions of the truth (an order exists, but loyalty points haven't been added yet).
- Harder debugging: a flow jumps between services and time gaps, so you need correlation IDs and distributed tracing to follow it.
- Ordering is not free: messages can arrive out of order (see below).
Request-reply over a message broker
Sometimes you want a reply, but you also want the buffering and routing of a broker. The standard pattern (RabbitMQ's documentation describes it as an RPC pattern using reply_to and correlation_id properties) works like this:
- The caller creates a reply queue it listens on.
- It sends each request with two extra fields: a reply-to address and a unique correlation ID.
- The worker processes the request and sends the result to the reply-to address with the same correlation ID.
- The caller matches each reply to its request by correlation ID, because replies can come back in any order.
The program below simulates this with in-memory queues. The pricing service takes different amounts of time per request, so replies return out of order:
import java.util.*;
import java.util.concurrent.*;
// Request-reply over a message broker, simulated with in-memory queues.
// The caller tags each request with a correlation ID and a reply queue, then matches replies by ID.
public class RequestReply {
record Message(String correlationId, String replyTo, String body) {}
static final BlockingQueue<Message> requestQueue = new LinkedBlockingQueue<>();
static final Map<String, BlockingQueue<Message>> replyQueues = new ConcurrentHashMap<>();
public static void main(String[] args) throws Exception {
// Pricing service: takes requests from the queue; slow ones finish later, so replies come back out of order.
ExecutorService workers = Executors.newCachedThreadPool(daemon());
daemon().newThread(() -> {
while (true) {
Message m = take(requestQueue);
workers.submit(() -> {
long work = m.body().endsWith("1") ? 300 : m.body().endsWith("2") ? 100 : 200;
sleep(work);
replyQueues.get(m.replyTo()).add(new Message(m.correlationId(), null, "ok after " + work + " ms"));
});
}
}).start();
// Caller: one private reply queue, one pending future per correlation ID.
String myReplyQueue = "replies-" + UUID.randomUUID();
replyQueues.put(myReplyQueue, new LinkedBlockingQueue<>());
Map<String, CompletableFuture<String>> pending = new ConcurrentHashMap<>();
daemon().newThread(() -> {
while (true) {
Message reply = take(replyQueues.get(myReplyQueue));
System.out.println(" reply arrived: " + reply.correlationId());
CompletableFuture<String> f = pending.get(reply.correlationId());
if (f != null) f.complete(reply.body()); // unknown IDs (late or duplicate replies) are ignored
}
}).start();
List<String> ids = List.of("req-1", "req-2", "req-3");
for (String id : ids) {
pending.put(id, new CompletableFuture<>());
requestQueue.add(new Message(id, myReplyQueue, "price for SKU-" + id.charAt(4)));
}
System.out.println("sent req-1, req-2, req-3");
for (String id : ids) {
String result = pending.get(id).get(2, TimeUnit.SECONDS); // always wait with a timeout
pending.remove(id);
System.out.println(id + " -> " + result);
}
}
static ThreadFactory daemon() {
return r -> { Thread t = new Thread(r); t.setDaemon(true); return t; };
}
static Message take(BlockingQueue<Message> q) {
try { return q.take(); } catch (InterruptedException e) { throw new RuntimeException(e); }
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
Output (the same on repeated runs):
sent req-1, req-2, req-3
reply arrived: req-2
reply arrived: req-3
reply arrived: req-1
req-1 -> ok after 300 ms
req-2 -> ok after 100 ms
req-3 -> ok after 200 ms
Replies arrived in the order 2, 3, 1, but each one was matched to the right request. Two details matter in real systems: the caller always waits with a timeout (the reply may never come) and it ignores replies with unknown IDs (late replies after a timeout or duplicates after a retry).
Use this pattern carefully. It brings back the waiting and timeouts of synchronous calls on top of broker complexity. It's useful when work must go through a queue (for rate limiting, routing or priority), not as a default replacement for a direct call.
The async request-reply pattern for long-running work
What if the client needs a result, but the work takes minutes, like generating a report, transcoding a video or processing a large import? Holding an HTTP request open that long breaks on timeouts and mobile networks.
A common HTTP pattern:
Client Server
│ POST /reports ───────────────► │ validate, save job, put it on a queue
│ ◄── 202 Accepted │
│ Location: /reports/jobs/77 │
│ │ worker processes the job in the background
│ GET /reports/jobs/77 ──────────► │
│ ◄── 200 {"status":"RUNNING"} │
│ ... │
│ GET /reports/jobs/77 ──────────► │
│ ◄── 200 {"status":"DONE", │
│ "url":"/reports/77.pdf"}│
The 202 Accepted status means "I've accepted your request, but I haven't finished it." Instead of polling, the client can also get the result through Server-Sent Events, a WebSocket, a webhook or an email or push notification. This gives users a fast response and keeps slow work off the request path.
Message ordering
In a FIFO-ordered queue with a single consumer (for example, RabbitMQ without requeues or an SQS FIFO queue), messages are processed in order. Not every queue promises this: Amazon SQS standard queues only try to keep order, even with one consumer. And once you add parallel consumers, partitions or retries, order can break in any system: "OrderCancelled" might be processed before "OrderPlaced."
You rarely need global order (it's expensive and limits throughput). Usually you need order per entity, such as per order or per user:
- Kafka keeps order within a partition. Use the order ID as the message key, so all events for one order go to the same partition.
- Amazon SQS FIFO queues keep order within a message group, set with a message group ID.
- Retries can still reorder messages. Include a version or sequence number in each event and have consumers ignore anything older than what they've already applied.
Synchronous vs asynchronous communication compared
| Synchronous | Asynchronous | |
|---|---|---|
| Caller behavior | Waits for the result | Sends and moves on |
| Temporal coupling | High: both sides must be up at the same time | Low: the receiver can process later |
| Result | Immediate success or failure | Delayed, delivered separately or not returned at all |
| User-facing latency | Sum of the calls in the chain | Only the time to store the message |
| Traffic spikes | Hit downstream services directly | Buffered in the queue |
| Error handling | Errors return straight to the caller | Retries, dead-letter queues, idempotent consumers, monitoring |
| Consistency | Easier to keep data in step | Eventual consistency |
| Debugging | Easier (one flow, one stack trace) | Harder (needs tracing and correlation IDs) |
| Infrastructure | Just the services | A broker to run or pay for |
| Typical technology | REST, GraphQL, SOAP, gRPC | RabbitMQ, Kafka, SQS, Google Pub/Sub, MQTT |
| Best for | Interactive actions that need an answer now | Background jobs, events, workflows, absorbing spikes |
How to decide between synchronous and asynchronous
Ask these questions for each interaction:
- Does the user or caller need the answer to continue? If yes, synchronous. If the user can see "processing" and be told later, asynchronous.
- What happens if the receiver is down? If the caller should keep working anyway, asynchronous.
- Is the traffic spiky? Queues absorb spikes that would overload a synchronous chain.
- How long does the work take? Seconds or more points to async or the 202 Accepted pattern.
- How many services care about this event? If several do, publish an event instead of calling each one.
- Can you live with eventual consistency here? If not, keep it synchronous or design compensation steps carefully.
- Can your team run the extra infrastructure? A broker is another system to monitor, secure and upgrade.
Most real systems use both. A checkout typically validates stock and authorizes payment synchronously, then publishes an event so email, loyalty, analytics and fulfillment happen asynchronously.
Asynchronous Messaging: Queues, Pub/Sub, Kafka and MQTT
The previous section covered when to go asynchronous. This section covers the tools. The picture below sums up the difference:
SYNCHRONOUS ASYNCHRONOUS
Order ─► Email (wait) Order ─► [ Broker: "OrderPlaced" ]
─► SMS (wait) │ │ │ │
─► Points (wait) Email SMS Points Analytics
─► Analytics (wait) (each reads when ready)
Checkout is as slow as the slowest Checkout finishes as soon as the
call and fails if any one fails message is safely stored
Queue vs pub/sub
- Message queue (point-to-point): each message is processed by one worker. Good for spreading jobs across workers: resizing images, sending emails.
- Publish/subscribe (pub/sub): each message goes to every subscriber group. Good for events many services care about: "OrderPlaced," "UserSignedUp."
Common messaging protocols and systems
| What it is | Strengths | Typical use | |
|---|---|---|---|
| AMQP 0-9-1 (RabbitMQ's main protocol) | Broker protocol with queues, exchanges and routing rules. (AMQP 1.0, the OASIS standard, is a different protocol with a different model; RabbitMQ supports it too.) | Flexible routing, acknowledgements, mature tooling | Background jobs, task queues, routing messages between services |
| Apache Kafka | Distributed log with its own binary protocol over TCP | Very high throughput, keeps messages for replay, ordered within a partition | Event streaming, activity feeds, data pipelines, the outbox pattern |
| MQTT | Very lightweight publish/subscribe protocol | Tiny overhead, works on poor networks, three delivery levels (QoS 0, 1, 2) | IoT devices, sensors, vehicle telemetry, mobile push-style updates |
| Cloud queues (e.g., Amazon SQS, Google Pub/Sub) | Managed services with HTTP-based APIs | No servers to run, scales automatically | Decoupling services in the cloud |
MQTT has an interesting origin: it was created in 1999 by Andy Stanford-Clark of IBM and Arlen Nipper to monitor oil pipelines over expensive, unreliable satellite links. That's why it's so small and tolerant of bad connections and why it fits IoT devices so well today. It's now an OASIS open standard.
Delivery guarantees in plain English
| Guarantee | Meaning | Risk |
|---|---|---|
| At most once | Sent once, never retried | Messages can be lost |
| At least once | Retried until acknowledged | Messages can be duplicated |
| Exactly once | Each message has its effect once | Only within specific systems (for example, Kafka transactions between Kafka topics); end to end, you still need idempotent consumers |
Most real systems use at-least-once delivery plus idempotent consumers: accept that duplicates happen and make processing the same message twice harmless (for example, by recording processed message IDs).
Backpressure: when consumers can't keep up
A queue absorbs spikes, but it can't absorb an endless flood. If producers keep sending faster than consumers can process, the backlog grows until memory or disk runs out or messages get so old they're useless. Backpressure is any way for a slow receiver to tell a fast sender to slow down.
You'll find it at many levels:
- TCP and HTTP/2 flow control: the receiver advertises how much data it can accept and the sender waits when that window is full. gRPC streams inherit this from HTTP/2.
- Pull-based consumers: Kafka consumers fetch messages at their own pace instead of having messages pushed at them.
- Reactive Streams (used by Project Reactor and RxJava): the subscriber requests a specific number of items and the publisher never sends more than that.
- Bounded queues: a queue with a maximum size that blocks or rejects new work when full, instead of growing forever.
- HTTP 429 Too Many Requests with a
Retry-Afterheader: the server tells clients to back off.
Without backpressure, a slow consumer doesn't just fall behind; it can take down the producer or the broker too. Watch queue depth and the age of the oldest message and decide in advance whether to slow producers, add consumers or shed low-priority work when the backlog grows.
Handling failed messages
Some messages fail every time, for example because of bad data. Retrying them forever blocks the queue and wastes resources. After a few attempts, move them to a dead-letter queue (DLQ), alert on it and inspect or replay those messages after fixing the cause.
Making Communication Reliable: Timeouts, Retries and Idempotency
Networks fail in boring, constant ways: slow responses, dropped connections, overloaded servers. Whatever protocol you choose, three habits make the difference between a small blip and an outage.
1. Always set timeouts
A call with no timeout can hang forever, holding a thread, a connection and memory. Under load, those pile up until your service falls over, even though the real problem was a different service. Set a timeout on every network call, based on how long that call normally takes (for example, a bit above its 99th-percentile latency), not a random large number. gRPC calls this a deadline and it can pass the remaining time along to downstream calls automatically.
2. Retry carefully: backoff and jitter
Retrying a failed request is often the right move. But if a service struggles and every client retries immediately, the retries become a flood that keeps it down. This is called a retry storm.
Two fixes, both described in the AWS Builders' Library article "Timeouts, retries and backoff with jitter":
- Exponential backoff: wait longer after each failure (1 s, 2 s, 4 s, 8 s).
- Jitter: add randomness to the wait, so clients don't all retry at the same moment.
The simulation below has 1,000 clients that all fail at the same instant and retry four times. It measures the busiest 100 ms window the server sees:
import java.util.*;
// 1,000 clients all fail at t=0 (a brief outage) and retry up to 4 times.
// Compare retry timing strategies by the busiest 100 ms window the server sees.
public class RetryStorm {
static final int CLIENTS = 1_000, RETRIES = 4;
static final long BASE_MS = 1_000, CAP_MS = 30_000;
interface Strategy { long delay(int attempt, Random rnd); }
static int busiestWindow(Strategy s, Random rnd) {
Map<Long, Integer> perWindow = new HashMap<>();
for (int c = 0; c < CLIENTS; c++) {
long t = 0;
for (int attempt = 0; attempt < RETRIES; attempt++) {
t += s.delay(attempt, rnd);
perWindow.merge(t / 100, 1, Integer::sum); // bucket each retry into a 100 ms window
}
}
return Collections.max(perWindow.values());
}
public static void main(String[] args) {
Strategy fixed = (a, r) -> 1_000; // retry every second
Strategy expo = (a, r) -> Math.min(CAP_MS, BASE_MS << a); // 1 s, 2 s, 4 s, 8 s
Strategy full = (a, r) -> (long) (r.nextDouble() * Math.min(CAP_MS, BASE_MS << a)); // random 0..expo
System.out.println("busiest 100 ms window (out of " + CLIENTS * RETRIES + " retries):");
System.out.println("fixed 1 s delay: " + busiestWindow(fixed, new Random(1)));
System.out.println("exponential backoff: " + busiestWindow(expo, new Random(1)));
System.out.println("exponential + full jitter: " + busiestWindow(full, new Random(1)));
}
}
Output:
busiest 100 ms window (out of 4000 retries):
fixed 1 s delay: 1000
exponential backoff: 1000
exponential + full jitter: 151
This result surprises many people. Exponential backoff alone didn't reduce the peak at all. All 1,000 clients still retried at exactly 1 s, then exactly 3 s and so on, in perfect sync, because they all failed at the same moment. Backoff spreads retries out over time for each client, but only jitter spreads clients out from each other. With jitter, the busiest window dropped from 1,000 requests to 151, about 6.6 times lower.
Also limit how many retries happen. Retry only errors that might succeed next time (timeouts, 503, 429 with a Retry-After header), not errors like 400 Bad Request. And avoid retrying at every layer: if the app, the service and the database client each make three attempts, one failed request can turn into 27 calls at the bottom layer (3 × 3 × 3).
3. Make writes idempotent
A timeout doesn't tell you whether the request failed. The server might have processed it and the response got lost. If the client retries a "charge ₹1,499" request, the customer might be charged twice.
The standard fix is an idempotency key: the client generates a unique ID for each operation and sends it with every attempt. The server remembers which keys it has already processed and returns the same result for repeats instead of doing the work again. Stripe's API documents exactly this with an Idempotency-Key header. GET, PUT and DELETE are idempotent by design in HTTP; POST needs this extra help.
Circuit breakers
If a service keeps failing, stop calling it for a short time instead of piling on. A circuit breaker watches failures and, past a threshold, "opens" and fails fast (or returns a fallback) for a while, then lets a few test requests through to see if the service has recovered. This protects both your callers and the struggling service.
Security Across Protocols
| Risk | What to do |
|---|---|
| Someone reads or changes traffic | TLS everywhere, including internal traffic; mTLS between services |
| Fake requests or webhooks | Authenticate every request (tokens, API keys, signed webhooks); verify signatures with constant-time comparison |
| Abuse and floods | Rate limiting per client, request size limits, connection limits; keep proxies patched (Rapid Reset showed protocol features can be abused) |
| Long-lived connections never closing | Idle timeouts, heartbeats, max connection age, re-authentication for WebSockets |
| Replay of old messages | Timestamps and nonces in signed messages; short-lived tokens |
| Leaking data in logs | Don't log full request bodies, tokens or personal data; redact by default |
| Malformed binary messages | Validate input sizes and types, even with Protobuf; set maximum message sizes in gRPC and brokers |
One subtle point: switching from JSON to a binary format doesn't make data secret. Protobuf bytes are easy to decode with the schema or even without it. Encryption comes from TLS, not from the format.
Monitoring Communication Between Services
When services talk over the network, most problems show up between them, so that's where you need visibility.
- Latency percentiles (p50, p95, p99) per endpoint and per caller. Averages hide the slow requests users actually notice.
- Error rates by type: client errors (
4xx), server errors (5xx), timeouts and connection failures, measured separately. - Retries and circuit breaker state. A rising retry rate is often the first sign of trouble.
- Open connections for WebSockets, SSE and gRPC streams, plus reconnect rates after deploys.
- Queue health: message backlog (consumer lag in Kafka), age of the oldest message and dead-letter queue size.
- Webhook health: delivery failures, signature failures and time to process.
- Distributed tracing. A single user action can cross ten services. Tracing tools follow it end to end by passing a trace ID with every call. The W3C Trace Context standard defines a
traceparentheader for this and OpenTelemetry supports it across languages, so traces survive hops between HTTP, gRPC and message queues.
How to Choose the Right Communication Protocol
Here's how the choices might look for a food delivery app. This is an illustrative design, not a description of any specific company.
| Feature | Protocol choice | Why |
|---|---|---|
| Browsing restaurants and menus | REST over HTTPS (HTTP/2 or HTTP/3 at the edge), cached by a CDN | Simple, cacheable reads; works on every device |
| Mobile home screen pulling data from many services | GraphQL (or a backend-for-frontend REST endpoint) | One request, only the fields the screen needs |
| Order service ↔ payment, restaurant and pricing services | gRPC with deadlines and mTLS | Fast, typed, internal calls |
| Customer watching order status | Server-Sent Events | Server pushes updates; client doesn't need to send much |
| Live chat between customer and delivery partner | WebSockets | Both sides send messages often |
| Delivery partner's phone sending GPS every few seconds | MQTT or a lightweight HTTP/WebSocket stream | Small messages, unreliable mobile networks |
| "OrderPlaced" → email, SMS, loyalty, analytics | Kafka (or another pub/sub system) | Decouples services; each processes at its own pace |
| Payment provider confirming payment | Signed webhooks + idempotent handler | Provider pushes the result; duplicates are expected |
| In-app voice call with delivery partner | WebRTC | Real-time media, low latency |
A quick decision guide
Who needs to talk and how?
│
├── A client needs an answer right now
│ ├── Public, browser, partner or cacheable? ──► REST (JSON over HTTPS)
│ ├── Many screens needing different data? ──► GraphQL (or a BFF)
│ └── Service-to-service, high volume, typed? ──► gRPC (Protobuf over HTTP/2)
│
├── The server needs to push updates to a client
│ ├── Server → client only? ──► Server-Sent Events
│ ├── Both directions, often? ──► WebSockets
│ └── Audio or video between users? ──► WebRTC
│
├── Another company's system needs to tell you something ──► Webhooks (signed, idempotent)
│
└── Work can happen later or many services react to one event
├── Spread jobs across workers? ──► Message queue (RabbitMQ, SQS)
├── Event stream with replay, high volume? ──► Kafka
└── Tiny devices on weak networks? ──► MQTT
My honest default: start with REST and JSON unless you have a clear reason not to. It's the easiest to build, debug, cache and hire for. Move internal hot paths to gRPC when performance or strict contracts matter, add SSE or WebSockets only for features that truly need push and introduce a message broker when synchronous chains start making your system slow or fragile.
Trade-offs at a Glance
| REST + JSON | GraphQL | gRPC + Protobuf | WebSockets | Message broker | |
|---|---|---|---|---|---|
| Latency | Good | Good (fewer round trips) | Very good | Very good after connect | Not for instant replies |
| Payload size | Larger | Client trims fields | Smallest | Depends on format | Depends on format |
| Scalability | Easy (stateless, cacheable) | Needs query cost limits | Easy with per-request balancing | Harder (stateful connections) | Very high |
| Complexity | Low | Medium | Medium | High | Medium to high |
| Debugging | Easiest | Easy | Needs tooling | Harder | Harder (async flows) |
| Browser support | Native | Native | Needs proxy | Native | Not directly |
| Coupling | Moderate | Moderate | Tight contract | Moderate | Loose |
Common Mistakes
1. No timeouts on network calls. One slow dependency can exhaust threads and take down healthy services.
2. Retrying without jitter or limits. As the simulation showed, backoff alone keeps clients in sync. Add jitter, cap attempts and retry at one layer.
3. Retrying non-idempotent writes. Without idempotency keys, retries create duplicate orders and double charges.
4. Using WebSockets when SSE would do. If only the server pushes, SSE is simpler and reconnects for you.
5. Short polling at scale. It looks harmless with 100 users and becomes a huge, mostly empty request load with a million.
6. Treating TCP as message-based. TCP is a byte stream. Without framing, messages merge or split.
7. Assuming async means reliable. Brokers deliver at least once. Without idempotent consumers and dead-letter queues, duplicates and poison messages cause chaos.
8. Exposing gRPC directly to browsers and partners. Use REST or gRPC-Web with a proxy at the edge; keep gRPC for internal traffic.
9. Skipping webhook signature checks. A public URL will receive fake requests eventually.
10. "It's internal, so no TLS." Internal networks get breached. Encrypt service-to-service traffic.
11. Choosing protocols by hype. gRPC, GraphQL and Kafka are great tools, but each adds complexity. Use them where they solve a real problem.
Communication Protocols Interview Questions (with Answers)
Beginner
Q1. What is a communication protocol?
A set of rules two systems follow to exchange data: how messages are formatted, in what order they're sent and how errors are handled. HTTP, TCP, UDP, WebSocket and MQTT are examples.
Q2. What's the difference between TCP and UDP?
TCP sets up a connection, guarantees delivery and keeps data in order, so it's used for web pages, APIs and databases. UDP has no handshake and no delivery or ordering guarantees, so it's faster and used for video calls, games, DNS and as the base for QUIC.
Q3. What's the difference between HTTP and HTTPS?
HTTPS is HTTP sent over TLS. TLS encrypts the data, protects it from being changed and proves the server's identity with a certificate.
Q4. What is a WebSocket?
A long-lived, two-way connection where both client and server can send messages at any time. It starts as an HTTP request and upgrades. It's used for chat, games and collaborative apps.
Intermediate
Q5. REST vs gRPC: when would you use each?
REST with JSON is simple, works in browsers and is easy to cache and debug, so it suits public APIs and simple services. gRPC uses Protobuf over HTTP/2, so it's faster, strongly typed and supports streaming, which suits high-volume internal service-to-service calls. Many systems use REST at the edge and gRPC inside.
Q6. What problem does HTTP/2 solve and what problem remains?
HTTP/2 lets many requests share one connection at the same time (multiplexing) and compresses headers, fixing HTTP/1.1's one-request-at-a-time limit. But it runs on TCP, so a single lost packet still blocks all streams on that connection. HTTP/3 fixes that by running on QUIC.
Q7. How would you push live updates from server to client?
Use Server-Sent Events if only the server sends updates, since it's simple and reconnects automatically. Use WebSockets if the client also sends frequent messages. Long polling is a fallback; short polling works only for slow-changing data or small scale.
Q8. What's the difference between a message queue and pub/sub?
In a queue, each message goes to one consumer, which is good for distributing work. In pub/sub, each message goes to every subscriber group, which is good for events that many services need to react to.
Advanced
Q9. Why does gRPC need special load balancing?
gRPC uses long-lived HTTP/2 connections that carry many requests. A connection-level (layer 4) load balancer assigns a whole connection to one server, so all requests on it go there and load becomes uneven. Use request-level (layer 7) balancing, such as Envoy or client-side load balancing.
Q10. How do you design retries that don't make an outage worse?
Retry only errors that may succeed later, cap the number of attempts, use exponential backoff with jitter so clients don't retry in sync, retry at only one layer, respect Retry-After, add a circuit breaker and make write operations idempotent with keys.
Q11. What does "exactly-once delivery" really mean?
Networks can't guarantee a message is delivered exactly once in general, because a lost acknowledgement looks the same as a lost message. Systems like Kafka offer exactly-once processing within their own boundaries using transactions. End to end, the practical approach is at-least-once delivery with idempotent consumers.
Q12. What was the HTTP/2 Rapid Reset attack and what does it teach?
Attackers opened HTTP/2 streams and immediately cancelled them, over and over, making servers do work for requests that were thrown away. Google, Cloudflare and Amazon reported record request rates in 2023. It teaches that protocol features have cost models attackers can exploit, so edge software must be patched and protected with limits.
Q13. Why can adding more synchronous dependencies hurt availability?
Because the availabilities multiply. A request that needs five services, each 99.9% available, succeeds only about 99.5% of the time. Reduce the chain by calling independent services in parallel, moving non-essential work to asynchronous events, caching and using fallbacks when a dependency fails.
Scenario-based
Q14. Design the communication for a ride-hailing app's live driver location.
Drivers send GPS updates every few seconds over a lightweight channel (MQTT or a WebSocket) to an ingestion service, which publishes them to a stream like Kafka partitioned by region or ride. A location service keeps the latest position in memory or Redis. Riders watching a trip receive updates over SSE or WebSockets. Use small binary messages, drop stale updates instead of retrying them and handle reconnects with backoff.
Q15. A payment provider sometimes sends the same webhook twice and some orders were marked paid twice. How do you fix it?
Verify the signature, then store the provider's event ID with a unique constraint before processing, inside the same transaction as the order update. If the event ID already exists, return 200 and do nothing. Also make the "mark as paid" update conditional (only if the order is currently unpaid).
Q16. Checkout is slow because the order service calls five other services one after another. What would you change?
Keep only what the user must wait for in the synchronous path (stock check, payment authorization) and call independent services in parallel with timeouts. Move everything else (email, SMS, loyalty points, analytics) to an asynchronous "OrderPlaced" event using the outbox pattern, so checkout finishes as soon as the order and event are saved.
FAQ
1. What are communication protocols in system design?
Communication protocols are the rules systems use to exchange data. In system design, they include transport protocols like TCP, UDP and QUIC; web protocols like HTTP and WebSockets; API styles like REST, GraphQL and gRPC; and messaging systems like Kafka, RabbitMQ (AMQP) and MQTT. Choosing them well affects speed, reliability, cost and security.
2. What is the difference between TCP and UDP in simple words?
TCP is like registered post: it confirms delivery, resends lost pieces and keeps everything in order, but it takes a moment to set up. UDP is like a loudspeaker announcement: it's instant, but there's no confirmation and no repeat. TCP suits web pages and APIs; UDP suits live video, games and DNS.
3. Is HTTP a TCP or UDP protocol?
HTTP/1.1 and HTTP/2 run over TCP. HTTP/3 runs over QUIC, which is built on top of UDP but adds its own reliability, ordering per stream and encryption. So the answer depends on the HTTP version.
4. Which is better, REST or gRPC?
Neither is better everywhere. REST is simpler, works natively in browsers and is easy to cache and debug, which makes it a good default for public APIs. gRPC is faster and more strictly typed, with built-in streaming, which makes it a strong choice for internal service-to-service communication.
5. When should I use WebSockets instead of HTTP?
Use WebSockets when the client and server both need to send messages frequently and with low delay, such as chat, multiplayer games or collaborative editing. If only the server needs to push updates, Server-Sent Events are usually simpler. For normal request-and-response actions, plain HTTP is best.
6. What is the difference between SSE and WebSockets?
Server-Sent Events are one-way, from server to client, over a normal HTTP response, with automatic reconnection built into browsers. WebSockets are two-way and use their own message format after an HTTP upgrade. SSE is simpler for notifications and live updates; WebSockets suit interactive, two-way features.
7. What is a webhook?
A webhook is an HTTP request one system sends to another when an event happens, like a payment succeeding or code being pushed. Instead of repeatedly asking for updates, you give the provider a URL to call. Webhooks should be verified with signatures and handled idempotently, because duplicates happen.
8. What is the difference between synchronous and asynchronous communication?
In synchronous communication, the caller waits for a response before continuing, as with a REST or gRPC call. In asynchronous communication, the caller sends a message, usually through a broker like Kafka or RabbitMQ and continues without waiting. Async improves resilience and scalability but makes data eventually consistent and debugging harder.
9. What is the difference between async I/O and asynchronous communication?
Async (non-blocking) I/O frees your thread while you wait for another service, but that service still has to be up and answer right away. Asynchronous communication sends the message to a broker so the receiver can process it later, even if it's down when you send. The key difference is whether both sides must be available at the same time.
10. What is the request-reply pattern in messaging?
It's a way to get a response over a message broker. The caller sends a request with a reply-to queue and a unique correlation ID, the worker sends the result back to that queue with the same ID and the caller matches replies to requests by ID. Always wait with a timeout and ignore unknown or late replies.
11. What is MQTT used for?
MQTT is a lightweight publish/subscribe protocol designed for small devices and unreliable networks. It's widely used for IoT sensors, smart home devices, vehicle telemetry and other cases where messages are small and connections are weak or expensive.
12. Why is Protobuf faster than JSON?
Protobuf is a binary format that doesn't send field names and stores numbers compactly, so messages are smaller and quicker to parse. In the example in this article, the same order took 46 bytes in Protobuf and 118 bytes in JSON. JSON is still easier to read and debug, so many teams use JSON for public APIs and Protobuf internally.
13. How do I explain communication protocols in a system design interview?
Name the protocol for each interaction and justify it: REST or GraphQL for client APIs, gRPC between services, SSE or WebSockets for live updates, webhooks for third-party events and a message broker for background work. Then mention reliability (timeouts, retries with jitter, idempotency) and security (TLS, authentication). Interviewers care most about why each choice fits.
Key Takeaways
- A protocol is an agreed set of rules for exchanging data. Protocols stack in layers and a failure low in the stack (like BGP and DNS in the Facebook outage) breaks everything above it.
- TCP is reliable and ordered; UDP is fast and unguaranteed; QUIC brings reliability and built-in encryption on top of UDP and powers HTTP/3.
- HTTP/2 multiplexes requests on one connection; HTTP/3 removes the remaining TCP-level blocking.
- REST is the simple default, GraphQL fits varied client screens and gRPC shines between services. API style and data format are separate choices.
- Protobuf messages are smaller and faster than JSON, but harder to read.
- For live updates, start with SSE; use WebSockets when both sides talk often.
- Webhooks must be signed, verified, fast to acknowledge and idempotent.
- Synchronous calls are simple but add latency and multiply failure risk across a chain; asynchronous messaging removes temporal coupling and absorbs spikes, at the cost of eventual consistency and harder debugging. Non-blocking code is not the same as asynchronous communication.
- Async messaging needs plans for duplicates, ordering per entity and failed messages (dead-letter queues).
- Timeouts, retries with jitter and idempotency keys matter more than which protocol you choose.
References and Further Reading
- Facebook Engineering, "More details about the October 4 outage", 2021.
- Google Cloud, "How it works: The novel HTTP/2 'Rapid Reset' DDoS attack", 2023.
- Cloudflare, "HTTP/2 Rapid Reset: deconstructing the record-breaking attack", 2023.
- LinkedIn Engineering, "LinkedIn Integrates Protocol Buffers With Rest.li for Improved Microservices Performance", 2023.
- InfoQ, "Why LinkedIn chose gRPC+Protobuf over REST+JSON", 2023.
- AWS Builders' Library, "Timeouts, retries and backoff with jitter".
- Stripe API documentation, "Idempotent requests".
- RabbitMQ, "Remote procedure call (RPC)" tutorial, on request-reply with
reply_toandcorrelation_id. - GitHub Docs, "Validating webhook deliveries".
- Protocol Buffers documentation, "Encoding".
- IETF, RFC 9000: QUIC (2021), RFC 9113: HTTP/2 (2022), RFC 9114: HTTP/3 (2022), RFC 6455: The WebSocket Protocol (2011).
- Roy Thomas Fielding, "Architectural Styles and the Design of Network-based Software Architectures," doctoral dissertation, University of California, Irvine, 2000.
- W3C, "Trace Context" recommendation.
