How Do Clients and Servers Communicate in Modern Systems?
Every system involving more than one component — a mobile app communicating with a backend, microservices exchanging data, or a system integrated with third parties — faces one fundamental question whose answer is far from trivial: what’s the best way for them to communicate? The choice between REST, GraphQL, gRPC, WebSocket, SSE, Webhooks, Message Queues, and tRPC isn’t just a technical preference — it determines the latency, scalability, operational complexity, and developer experience the team will live with for years. This article discusses each pattern in depth: where it comes from, how it works at the protocol level, what its strengths and limitations are, and when it’s the right choice.
The Big Picture: The Dimensions That Distinguish
Before diving into each technology, it’s worth understanding the dimensions that distinguish one communication pattern from another. This helps evaluate each choice with a consistent framework.
flowchart TD
Q1{Communication\nsynchronous\nor async?} -- Synchronous --> Q2{Who\ninitiates the\ncommunication?}
Q1 -- Asynchronous --> Q3{Need ordering\nand durability?}
Q2 -- Client --> Q4{Need full\ncontrol over\ndata structure?}
Q2 -- Server push --> Q5{Two-way\nor one-way?}
Q4 -- Yes --> GQL[GraphQL]
Q4 -- No --> Q6{Internal service\nor public API?}
Q6 -- Internal --> GRPC[gRPC]
Q6 -- Public --> REST[REST]
Q5 -- Two-way --> WS[WebSocket]
Q5 -- One-way --> SSE[Server-Sent Events]
Q3 -- Yes, need durability --> MQ[Message Queue\n/ Event Streaming]
Q3 -- Not needed --> WH[Webhook]
style GQL fill:#e3f2fd,stroke:#1e88e5
style GRPC fill:#e8f5e9,stroke:#43a047
style REST fill:#f3e5f5,stroke:#8e24aa
style WS fill:#fff3e0,stroke:#fb8c00
style SSE fill:#fce4ec,stroke:#e91e63
style MQ fill:#e0f2f1,stroke:#00897b
style WH fill:#fff8e1,stroke:#fdd835This diagram is a starting point — not a final decision. Every technology has nuances that a single decision tree can’t capture. The following sections discuss those nuances.
REST
REST (Representational State Transfer) is the most widely used communication pattern in the web world. Almost every engineer has built or consumed a REST API, but not everyone understands why it was designed that way and what the consequences of its principles are.
Origins and Design Principles
Roy Fielding introduced REST in his doctoral dissertation in 2000 as a critique of SOAP and the overly complex web systems of that era. REST isn’t a protocol — it’s an architectural style, a set of constraints that, when followed, produce systems that are scalable, stateless, and easily cacheable.
The six main REST constraints often overlooked in real implementations:
- Stateless: every request must contain all the information the server needs. The server doesn’t store state from previous requests.
- Client-server separation: the client doesn’t need to know how the server stores data; the server doesn’t need to know how the client renders UI.
- Cacheable: responses must define whether they can be cached, for how long, and by whom.
- Uniform interface: resources are identified by URL, manipulated via representations (JSON/XML), messages are self-descriptive, and HATEOAS.
- Layered system: the client doesn’t need to know whether it’s connected directly to the server or through a load balancer, CDN, or proxy.
- Code on demand (optional): the server can send code for the client to execute (like JavaScript).
How It Works
sequenceDiagram
participant C as Client
participant LB as Load Balancer
participant API as API Server
participant DB as Database
C->>LB: HTTP GET /users/42
LB->>API: Forward request
API->>DB: SELECT * FROM users WHERE id=42
DB-->>API: Row data
API-->>LB: 200 OK {"id":42,"name":"Alice",...}
LB-->>C: 200 OK {"id":42,"name":"Alice",...}
Note over C,API: Stateless — every request is independent
Note over API: Server doesn't remember previous requestsImplementation Example (Go)
// REST API handler that follows HTTP principles correctly
package main
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// CORRECT: use semantically appropriate HTTP methods and status codes
func getUserHandler(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.Atoi(idStr)
if err != nil {
// 400 Bad Request — invalid client input
http.Error(w, `{"error":"invalid user id"}`, http.StatusBadRequest)
return
}
user, err := getUserByID(id)
if err != nil {
// 404 Not Found — resource doesn't exist
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
return
}
// Set the Cache-Control header — often forgotten but important
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=60")
json.NewEncoder(w).Encode(user)
}
// ANTI-PATTERN: all requests to one endpoint with the action in the body
// POST /api {"action": "getUser", "id": 42}
// ✗ This isn't REST, this is RPC disguised as REST
// ✗ Can't be cached, can't use HTTP method semantics
When REST Is Right and When It Isn’t
USE REST if:
✓ Public APIs consumed by external developers
✓ Straightforward CRUD operations
✓ Teams with diverse tooling and backgrounds
✓ Need cacheability at the HTTP and CDN level
✓ The business resource model is clear and stable
CONSIDER ALTERNATIVES if:
✗ Frontends need varying data subsets (→ GraphQL)
✗ Internal service-to-service communication with high throughput (→ gRPC)
✗ Need real-time push notifications from the server (→ WebSocket / SSE)
✗ Over-fetching and under-fetching become real problems on mobile (→ GraphQL)
GraphQL
GraphQL came to solve a specific problem that appears when REST is used for applications with very varied data needs — especially mobile apps and frontends with many different views.
Origins and Motivation
Facebook developed GraphQL internally starting in 2012 to solve a very concrete problem: their mobile apps needed different data for each view, while the existing REST APIs always returned too much or too little data. The solution was giving clients full control to define the data structure they needed.
How It Works
sequenceDiagram
participant C as Client (React / Mobile)
participant GW as GraphQL Server
participant US as User Service
participant OS as Order Service
C->>GW: POST /graphql\n{ user(id:42) { name orders { id total } } }
GW->>US: Resolve user field
US-->>GW: {id:42, name:"Alice"}
GW->>OS: Resolve orders field for user 42
OS-->>GW: [{id:1,total:150},{id:2,total:75}]
GW-->>C: { user: { name:"Alice", orders:[...] } }
Note over C,GW: Client only receives the fields it requested
Note over GW: One request can resolve data from many servicesThree Core Concepts
Queries are used to read data. The client defines exactly which fields it needs — no more, no less.
Mutations are used to change data. Unlike REST, which uses different HTTP methods, GraphQL uses the mutation keyword as its convention.
Subscriptions are used for real-time data. The server pushes updates to the client every time the subscribed data changes — usually using WebSocket underneath.
# Query — fetch only the fields this view needs
query GetUserProfile($id: ID!) {
user(id: $id) {
name
email
# No need for other fields not displayed in this view
recentOrders(limit: 3) {
id
total
status
}
}
}
# Mutation — change data
mutation UpdateEmail($userId: ID!, $newEmail: String!) {
updateUserEmail(userId: $userId, email: $newEmail) {
id
email
updatedAt
}
}
# Subscription — receive real-time updates
subscription OnOrderStatusChange($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
id
status
updatedAt
}
}
GraphQL makes the N+1 query problem more likely because clients can freely request deep relational data. Use the DataLoader pattern to batch database requests, and apply query depth limiting to prevent clients from making queries that are too deep and expensive.
gRPC
gRPC is an RPC (Remote Procedure Call) framework built by Google on top of HTTP/2 and Protocol Buffers. It’s designed for internal service-to-service communication that needs high performance and strict type safety.
Origins and Motivation
Google needed an efficient way to connect their hundreds of internal microservices. The solution they developed — Stubby — was eventually released as gRPC in 2016. Behind it are two key components: HTTP/2 as the transport layer, and Protocol Buffers as the serialization format.
Protocol Buffers (Protobuf) is a binary format far more compact than JSON, and because it’s strongly typed, it generates client and server code automatically from a single schema definition (a .proto file).
How It Works
sequenceDiagram
participant SA as Service A (Go)
participant SB as Service B (Python)
Note over SA,SB: Both generated from the same .proto
SA->>SB: Binary Protobuf over HTTP/2\nCreateOrder(userId=42, items=[...])
Note over SB: Deserialize Protobuf\nRun business logic
SB-->>SA: Binary Protobuf\nOrderResponse(orderId=999, status=CREATED)
Note over SA: Deserialize Protobuf\nContinue processingFour gRPC Communication Patterns
gRPC supports four patterns that REST doesn’t have natively:
flowchart LR
subgraph Unary["1. Unary RPC"]
C1[Client] -->|one request| S1[Server]
S1 -->|one response| C1
end
subgraph ServerStream["2. Server Streaming"]
C2[Client] -->|one request| S2[Server]
S2 -->|stream responses| C2
end
subgraph ClientStream["3. Client Streaming"]
C3[Client] -->|stream requests| S3[Server]
S3 -->|one response| C3
end
subgraph BiStream["4. Bidirectional Streaming"]
C4[Client] <-->|two-way stream| S4[Server]
end// order.proto — this single file defines the contract for all languages
syntax = "proto3";
package order;
service OrderService {
// Unary: create one order
rpc CreateOrder(CreateOrderRequest) returns (OrderResponse);
// Server streaming: watch order status in real time
rpc WatchOrderStatus(WatchRequest) returns (stream OrderStatus);
// Client streaming: bulk upload orders at once
rpc BulkCreateOrders(stream CreateOrderRequest) returns (BulkResponse);
}
message CreateOrderRequest {
int64 user_id = 1;
repeated OrderItem items = 2;
}
message OrderItem {
int64 product_id = 1;
int32 quantity = 2;
}
message OrderResponse {
int64 order_id = 1;
string status = 2;
int64 created_at = 3;
}
WebSocket
WebSocket solves a fundamental HTTP problem: HTTP is a request–response protocol, meaning the server can’t send data to the client without first being prompted by a client request. For applications needing two-way real-time communication, this is a serious obstacle.
How It Works
WebSocket starts as a regular HTTP request (called the “handshake”), then gets upgraded into a persistent connection that both sides can use to send messages at any time.
sequenceDiagram
participant C as Client (Browser / App)
participant S as Server
C->>S: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade
S-->>C: 101 Switching Protocols
Note over C,S: HTTP connection upgraded to WebSocket\nPersistent connection open
S-->>C: {"type":"chat","msg":"Hello Alice!"}
C-->>S: {"type":"chat","msg":"Hello too!"}
S-->>C: {"type":"notification","msg":"Bob joined"}
C-->>S: {"type":"typing","status":true}
Note over C,S: Both can send at any time\nwithout waiting for a requestWhen WebSocket and Not SSE
WebSocket is the right choice when the client also needs to send data to the server frequently — chat, multiplayer games, collaborative editing. If only the server needs to push data to the client (monitoring dashboards, live feeds), SSE is simpler and sufficient.
WebSocket Server-Sent Events (SSE)
───────────────────────── ─────────────────────────
✓ Full-duplex ✓ Server-to-client only
✓ Binary and text ✓ Text only (UTF-8)
✓ Custom subprotocol ✓ Built on HTTP (no upgrade)
✗ More complex ✓ Auto-reconnect built-in
✗ No automatic reconnect ✓ HTTP/2 multiplexing
✗ Can't be cached ✓ Can pass through HTTP proxies
Server-Sent Events (SSE)
SSE is often underestimated because it looks “simpler” than WebSocket. But its simplicity is its strength — it runs over regular HTTP, supports auto-reconnect natively, and works well with HTTP/2 multiplexing.
How It Works
sequenceDiagram
participant C as Client (Browser)
participant S as Server
C->>S: GET /events\nAccept: text/event-stream
S-->>C: HTTP 200\nContent-Type: text/event-stream\nConnection open...
Note over S: An event occurs
S-->>C: data: {"type":"price","symbol":"AAPL","value":182.5}\n\n
Note over S: Another event occurs
S-->>C: data: {"type":"price","symbol":"GOOG","value":175.2}\n\n
Note over C: Connection dropped (network issue)
C->>S: GET /events\nLast-Event-ID: 1234
Note over S: Resume from event ID 1234
S-->>C: HTTP 200 — reconnected, resumed from checkpointSSE is ideal for use cases like streaming AI output (as ChatGPT does), live log tailing, real-time stock prices, and progress tracking for long-running jobs — all cases where the server pushes data but the client doesn’t need to send back frequently.
Webhooks
A webhook is the opposite of polling. Instead of the client asking the server every few seconds “any new events?”, the server comes to the client when an event happens. This is a very efficient integration pattern for event-driven systems.
How It Works
sequenceDiagram
participant User as User / External System
participant PG as Payment Gateway
participant App as Your Application
participant DB as Database
User->>PG: Pay invoice
PG->>PG: Process payment
Note over PG: Payment successful
PG->>App: POST /webhooks/payment\n{"event":"payment.success","invoice_id":123,"amount":500000}
App->>App: Verify HMAC signature
App->>DB: UPDATE invoice SET status='paid'
App->>App: Send confirmation email to user
App-->>PG: 200 OK
Note over PG: Webhook considered successfully receivedOften Forgotten: Idempotency and Verification
Webhooks can be sent more than once if the first delivery fails or times out. Implementations that don’t account for this can cause repeated side effects.
// CORRECT: an idempotent webhook handler that verifies signatures
func handlePaymentWebhook(w http.ResponseWriter, r *http.Request) {
// 1. Verify the request really comes from Stripe/the payment gateway
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("Stripe-Signature")
if err := verifyWebhookSignature(payload, signature, webhookSecret); err != nil {
// 400 Bad Request — reject unverified webhooks
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
var event WebhookEvent
json.Unmarshal(payload, &event)
// 2. Idempotency check — see if this event has already been processed
if alreadyProcessed(event.ID) {
// 200 OK — acknowledge but don't reprocess
w.WriteHeader(http.StatusOK)
return
}
// 3. Process the event and mark it as processed atomically
processPaymentEvent(event)
markAsProcessed(event.ID)
w.WriteHeader(http.StatusOK)
}
// ANTI-PATTERN: no signature verification, no duplicate check
// func handleWebhook(w http.ResponseWriter, r *http.Request) {
// var event WebhookEvent
// json.NewDecoder(r.Body).Decode(&event)
// processEvent(event) // ✗ can be executed repeatedly
// }
Never process a webhook without verifying its signature. An unprotected webhook endpoint can be exploited by anyone to trigger actions in your system — like marking a payment as successful without a real transaction.
Message Queues and Event Streaming
Message Queues and Event Streaming are the backbone of truly asynchronous, loosely coupled architectures. Unlike all the previous patterns, which are synchronous or semi-synchronous, here producers and consumers run completely independently of each other.
Message Queue vs Event Streaming
This distinction is often confused:
| Aspect | Message Queue (RabbitMQ, SQS) | Event Streaming (Kafka, Kinesis) |
|---|---|---|
| Consumption model | Messages deleted after consumption | Events persist, can be replayed |
| Consumers | Usually one consumer per message | Many independent consumer groups |
| Ordering | Per-queue (limited) | Per-partition (strong) |
| Retention | Until consumed | Time- or size-based |
| Main use cases | Task queues, job distribution | Event sourcing, audit logs, analytics |
| Throughput | Medium | Very high |
How It Works
sequenceDiagram
participant OS as Order Service
participant MQ as Message Broker\n(Kafka / RabbitMQ)
participant NS as Notification Service
participant INV as Inventory Service
participant AN as Analytics Service
OS->>MQ: Publish event\n{"type":"order.created","orderId":999,...}
Note over MQ: Event stored and\ndistributed to subscribers
MQ->>NS: Deliver event (Consumer Group A)
NS->>NS: Send confirmation email to user
MQ->>INV: Deliver event (Consumer Group B)
INV->>INV: Decrease product stock
MQ->>AN: Deliver event (Consumer Group C)
AN->>AN: Record to the data warehouse
Note over OS,AN: Order Service doesn't know\nwho consumes its eventsThe beauty of this pattern is true decoupling: the Order Service doesn’t need to know that a Notification Service, Inventory Service, and Analytics Service exist. It just publishes events — who listens is each consumer’s business.
tRPC
tRPC is a very different approach from the others — it doesn’t define a new protocol or format, but leverages the TypeScript type system to create a type-safe RPC experience between frontend and backend without a separate schema.
Motivation
The problem tRPC solves is very specific: when both frontend and backend are written in TypeScript, why should there be a separate schema (OpenAPI, GraphQL SDL) that needs generating, maintaining, and syncing? tRPC’s answer: there doesn’t need to be.
How It Works
sequenceDiagram
participant FE as Frontend (Next.js)
participant BE as Backend (Node.js)
participant DB as Database
Note over FE,BE: Direct type inference\nFrontend knows the exact response shape\nwithout a separate schema
FE->>BE: trpc.user.getById.query({ id: 42 })
BE->>DB: SELECT * FROM users WHERE id=42
DB-->>BE: Row data
BE-->>FE: { id: 42, name: "Alice", email: "..." }
Note over FE: TypeScript knows exactly\nthis response structure\nwithout generating any code// Backend — router definition
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
// This return type is automatically inferred on the frontend
return await db.user.findUnique({ where: { id: input.id } });
}),
update: publicProcedure
.input(z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.update({
where: { id: input.id },
data: { name: input.name, email: input.email },
});
}),
});
// Frontend — consume with full type safety, no generation at all
// components/UserProfile.tsx
const { data: user } = trpc.user.getById.useQuery({ id: 42 });
// TypeScript already knows: user.name, user.email exist
// user.nonExistentField will error at compile time
tRPC is a very powerful tool for fullstack TypeScript projects, but it has a constraint that can’t be ignored: the client and server must use the same JavaScript/TypeScript runtime.
Comprehensive Comparison
The following table summarizes all the important dimensions of each communication pattern for direct comparison.
| REST | GraphQL | gRPC | WebSocket | SSE | Webhook | Msg Queue | tRPC | |
|---|---|---|---|---|---|---|---|---|
| Communication direction | C→S | C→S | C→S / stream | Two-way | S→C | S→S | Async | C→S |
| Data format | JSON/XML | JSON | Protobuf (binary) | Free | Text | JSON | Free | JSON |
| Transport | HTTP/1.1+ | HTTP/1.1+ | HTTP/2 | WebSocket | HTTP | HTTP | TCP | HTTP |
| Type safety | Manual | Schema | Protobuf | None | None | None | None | TypeScript |
| Cacheability | ✓ Excellent | ✗ Hard | ✗ No | ✗ No | ✗ No | ✗ No | ✗ No | ✗ No |
| Real-time | ✗ Polling | Subscription | Streaming | ✓ Native | ✓ Native | ✓ Event-driven | ✓ Async | ✗ Polling |
| Multi-language | ✓ Universal | ✓ Universal | ✓ Universal | ✓ Universal | ✓ Universal | ✓ Universal | ✓ Universal | ✗ JS/TS only |
| Setup overhead | Low | Medium | High | Medium | Low | Low | High | Low |
| Best for | Public APIs | Varied frontends | Internal services | Real-time chat | Live feeds | Integrations | Event-driven | Fullstack TS |
Decision Tree — Choosing the Right One
flowchart TD
START[Start here] --> Q1{Is your stack\nfullstack TypeScript?}
Q1 -- Yes, small team --> TRPC[tRPC\n✓ Best developer experience\nfor fullstack TS]
Q1 -- No / Large team --> Q2
Q2{Does the communication\nneed real-time?} -- No --> Q3
Q2 -- Yes --> Q4{Two-way\nor server push only?}
Q4 -- Full two-way --> WS[WebSocket\n✓ Chat, games, collaborative]
Q4 -- Server push only --> SSE_NODE[SSE\n✓ Live feeds, monitoring,\nAI streaming output]
Q3{Who consumes\nthe API?} -- External developers --> REST_NODE[REST\n✓ Public APIs, CRUD,\nease of access]
Q3 -- Complex frontend --> Q5
Q3 -- Internal services --> Q6
Q5{Lots of data\nvariation per view?} -- Yes --> GQL[GraphQL\n✓ BFF pattern, mobile,\ncomplex data relations]
Q5 -- No --> REST_NODE
Q6{Need synchronous\nresponses?} -- Yes --> GRPC_NODE[gRPC\n✓ Internal microservices,\nhigh performance]
Q6 -- No / Event-driven --> Q7
Q7{Need integration\nwith external systems?} -- Yes --> WH[Webhook\n✓ Payment gateways,\nCI/CD, SaaS integrations]
Q7 -- No, internal --> MQ_NODE[Message Queue\n✓ Decoupled services,\nasync processing]
style TRPC fill:#e8f5e9,stroke:#43a047
style WS fill:#fff3e0,stroke:#fb8c00
style SSE_NODE fill:#fce4ec,stroke:#e91e63
style REST_NODE fill:#f3e5f5,stroke:#8e24aa
style GQL fill:#e3f2fd,stroke:#1e88e5
style GRPC_NODE fill:#e0f7fa,stroke:#00acc1
style WH fill:#fff8e1,stroke:#fdd835
style MQ_NODE fill:#e0f2f1,stroke:#00897bRecommendations for Real-World Scenarios
E-commerce Applications (Web + Mobile)
The right architecture isn’t choosing one pattern — it’s combining the right ones for each need.
flowchart LR
WEB[Web Frontend] -->|REST / GraphQL| API[API Gateway]
MOB[Mobile App] -->|GraphQL| API
API --> OS[Order Service]
API --> PS[Product Service]
API --> US[User Service]
OS -->|Publish event| MQ[(Kafka)]
MQ --> NS[Notification\nService]
MQ --> INV[Inventory\nService]
PG[Payment Gateway] -->|Webhook| OS
WEB <-->|WebSocket| NOTIF[Realtime\nNotification]- GraphQL for the mobile app — fetched data is minimal per screen
- REST for the simpler Web frontend that can leverage browser caching
- Webhooks from the payment gateway for payment notifications
- Kafka for propagating order events to other services asynchronously
- WebSocket for real-time notifications to the browser
Internal SaaS Platform
REST → Public API for customer integrations
gRPC → Internal microservice-to-microservice communication
Kafka → Event bus for audit logs and analytics
Webhook → Notify customers when events occur on the platform
SSE → Live logs and progress for long-running jobs in the UI
Summary
- No single pattern fits all use cases — mature production systems almost always use several patterns at once, each for its own needs.
- REST is a solid default for public APIs and CRUD operations — mature, abundant tooling, and excellent cacheability.
- GraphQL fits when frontends need high flexibility in selecting data — but needs extra attention to the N+1 problem and query complexity.
- gRPC is the best choice for internal service-to-service communication needing performance and type safety — but unsuitable for public APIs due to limited browser tooling.
- WebSocket for two-way real-time; SSE for one-way server push — choose SSE if you don’t need frequent client-to-server communication because it’s simpler and native to HTTP.
- Webhooks must always have their signatures verified and handlers must be idempotent — duplicate delivery is normal behavior that must be anticipated.
- Message Queue vs Event Streaming — Queues for task distribution (messages deleted after consumption); Streaming for event logs that many consumers can replay.
- tRPC is the best developer experience for fullstack TypeScript — but it’s very opinionated and only usable within the JavaScript/TypeScript ecosystem.