How Do Clients and Servers Communicate in Modern Systems?
16 min read

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:#fdd835

This 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 requests

Implementation 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 services

Three 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 processing

Four 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 request

When 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 checkpoint

SSE 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 received

Often 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:

AspectMessage Queue (RabbitMQ, SQS)Event Streaming (Kafka, Kinesis)
Consumption modelMessages deleted after consumptionEvents persist, can be replayed
ConsumersUsually one consumer per messageMany independent consumer groups
OrderingPer-queue (limited)Per-partition (strong)
RetentionUntil consumedTime- or size-based
Main use casesTask queues, job distributionEvent sourcing, audit logs, analytics
ThroughputMediumVery 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 events

The 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.

RESTGraphQLgRPCWebSocketSSEWebhookMsg QueuetRPC
Communication directionC→SC→SC→S / streamTwo-wayS→CS→SAsyncC→S
Data formatJSON/XMLJSONProtobuf (binary)FreeTextJSONFreeJSON
TransportHTTP/1.1+HTTP/1.1+HTTP/2WebSocketHTTPHTTPTCPHTTP
Type safetyManualSchemaProtobufNoneNoneNoneNoneTypeScript
Cacheability✓ Excellent✗ Hard✗ No✗ No✗ No✗ No✗ No✗ No
Real-time✗ PollingSubscriptionStreaming✓ Native✓ Native✓ Event-driven✓ Async✗ Polling
Multi-language✓ Universal✓ Universal✓ Universal✓ Universal✓ Universal✓ Universal✓ Universal✗ JS/TS only
Setup overheadLowMediumHighMediumLowLowHighLow
Best forPublic APIsVaried frontendsInternal servicesReal-time chatLive feedsIntegrationsEvent-drivenFullstack 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:#00897b

Recommendations 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.

Portfolio