paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

HTTP vs RPC vs WebSockets: Choosing the Right Communication Protocol for Your System

A practical engineering guide to understanding the fundamental differences between HTTP, RPC, and WebSockets - when each model fits, where each breaks down, and how to make the decision that will age well.

Introduction

When a system needs two components to communicate, the first decision is rarely about which database to use or how to structure the domain model. It is about the communication protocol: how will the caller send information to the callee, how will the response come back, and what happens when something goes wrong in between. This decision has cascading consequences - for latency, for scalability, for developer experience, and for operational complexity. Yet it is often made by habit or by default rather than by deliberate reasoning.

The three dominant models in modern backend and distributed systems work are HTTP (specifically HTTP request-response semantics over REST or similar conventions), RPC (Remote Procedure Call, as implemented by systems like gRPC, Thrift, or tRPC), and WebSockets. These are not competing technologies in a zero-sum race. They are different communication paradigms that excel in different contexts. HTTP is optimized for stateless resource exchange. RPC is optimized for strongly-typed procedure invocation. WebSockets are optimized for persistent, bidirectional, low-latency message exchange. The engineer who understands these differences at the protocol level - not just the framework API level - will make better architectural decisions, write better client code, and diagnose failures more quickly.

This article works through all three in depth: their mechanics, their mental models, their practical tradeoffs, and the decision logic that should guide protocol selection. The goal is not to declare a winner. It is to give you a clear enough picture of each that you can reason about the choice for a specific system rather than defaulting to what you used last time.

The Problem: Not All Communication Is the Same

Before comparing the three approaches, it is worth articulating the problem space. Communication between distributed components encompasses several distinct patterns, and conflating them leads to poor protocol choices.

Request-response: A caller sends a message and waits for a single reply. This is the dominant pattern for CRUD operations, queries, and command execution. HTTP and RPC both handle this well. The caller is active; the callee is reactive.

Server-initiated push: The server needs to send data to the client without the client asking. Examples include live notifications, real-time dashboards, and event delivery. Neither plain HTTP nor RPC handles this naturally in their basic forms. WebSockets handle it natively. Server-Sent Events (SSE) handle a subset of it over HTTP.

Streaming: Either side needs to send a continuous or chunked flow of data - log tailing, large file transfers, audio/video frames, or incremental query results. HTTP supports this poorly at the application layer (chunked transfer encoding exists but is awkward to consume). gRPC supports it natively as a first-class concept. WebSockets support it as a raw bytestream.

Bidirectional exchange: Both sides need to send messages independently, interleaved, without waiting for the other to finish. Think collaborative editing, multiplayer game state, or chat. WebSockets are the right answer here. HTTP and basic RPC are not - they assume one side initiates and the other responds.

Choosing a protocol without knowing which of these patterns your system actually requires is the root cause of most protocol-mismatch problems. Engineers who pick HTTP for a real-time notification system will spend weeks building polling infrastructure that approximates what WebSockets give for free. Engineers who pick WebSockets for a simple CRUD API will wrestle with reconnection logic, stateful server infrastructure, and load balancer configuration that they did not need to deal with.

HTTP: Stateless Resource Exchange at Scale

How HTTP Actually Works

HTTP is a request-response protocol. A client opens a TCP connection (or reuses an existing one via HTTP keep-alive or HTTP/2 multiplexing), sends a request - a method, a target URL, headers, and optionally a body - and the server sends back a response: a status code, headers, and optionally a body. The connection may be kept alive for subsequent requests, but from the application layer's perspective, each request-response pair is independent.

HTTP/1.1 introduced persistent connections by default, eliminating the TCP handshake overhead on every request when communicating with the same server. HTTP/2 went further: it multiplexes multiple requests over a single TCP connection using binary framing, eliminating head-of-line blocking at the HTTP layer (though not at the TCP layer). HTTP/3, built on QUIC over UDP, eliminates TCP head-of-line blocking entirely and improves performance on lossy networks. These transport-level improvements are invisible to application code but matter significantly for latency-sensitive bulk API traffic.

The fundamental property that makes HTTP distinctive is statelessness. Each request contains all the information needed to process it. The server does not maintain session state between requests unless you explicitly add it (via tokens, cookies, or other mechanisms). This statelessness is what makes HTTP APIs horizontally scalable by default: any server instance can handle any request. You can put an arbitrarily large fleet of servers behind a load balancer with no affinity requirements. This is not true of WebSockets, where a client's persistent connection is bound to a specific server instance.

REST and the Resource Model

When engineers say "HTTP API," they usually mean a RESTful HTTP API - one that organizes communication around resources (nouns) identified by URLs, uses HTTP methods (GET, POST, PUT, PATCH, DELETE) to express operations on those resources, and uses status codes to signal outcomes. REST as an architectural style was formalized by Roy Fielding in his 2000 dissertation, but in practice "REST" in most codebases means "JSON over HTTP with URL-based routing" rather than strict adherence to Fielding's constraints.

The resource orientation of REST fits naturally when you are modeling a domain as a set of entities with standard CRUD lifecycles. GET /users/42 is clear. DELETE /orders/99 is clear. But when operations are inherently procedural - "send a password reset email", "merge two accounts", "run a batch reindex" - the resource model starts to feel like a constraint rather than a guide. Engineers invent workarounds: POST /users/42/password-reset, POST /account-merges, POST /reindex-jobs. These work, but they signal that the resource model is being strained, and an explicit RPC interface might express the intent more clearly.

HTTP Strengths and Fit

HTTP shines in specific contexts. Public-facing APIs benefit from HTTP's universality: every HTTP client, every language runtime, every browser, every command-line tool speaks HTTP. There is no client library to distribute or version. A developer with curl can call your API without any SDK. This universality is a genuine competitive advantage for developer-facing APIs, where reducing friction for third-party consumers is a priority.

HTTP also integrates naturally with the entire web infrastructure stack: CDNs can cache GET responses, WAFs understand HTTP traffic, load balancers inspect HTTP headers, APM tools parse HTTP status codes and URLs. This ecosystem integration is invisible until you step outside it - at which point you realize how much infrastructure you have been getting for free.

RPC: Procedure-Oriented Communication with Strong Contracts

The RPC Mental Model

Where HTTP thinks in resources and operations on resources, RPC thinks in functions. You have a service, it has methods, and you call those methods. From the caller's perspective, it looks like invoking a local function: you pass arguments, you get a return value, you handle exceptions. The network is an implementation detail managed by the framework.

This is not mere syntactic sugar. The procedure orientation changes how you think about the interface. Instead of asking "what resource should I model, and which HTTP verb applies?" you ask "what does the caller need to be able to do?" Those are different design questions that produce different interfaces. RPC interfaces tend to be more expressive for command-heavy domains - financial operations, workflow orchestration, machine learning inference - where the action matters more than the entity being acted upon.

Modern RPC frameworks add something that plain HTTP does not have by default: a formal, machine-readable interface contract. gRPC uses Protocol Buffer .proto files. Apache Thrift uses its own IDL. tRPC uses TypeScript's type system. This contract drives code generation (client stubs, server skeletons, type definitions) and makes breaking changes detectable at compile time rather than at runtime in production.

gRPC in Depth

gRPC is the most widely deployed modern RPC framework. It uses HTTP/2 as its transport (not HTTP/1.1), Protocol Buffers as its serialization format, and a .proto IDL as its schema language. It supports four communication patterns: unary (one request, one response), server streaming, client streaming, and bidirectional streaming - all as first-class protocol features, not application-level workarounds.

The HTTP/2 transport gives gRPC meaningful advantages over HTTP/1.1 REST for internal service-to-service communication: multiplexed requests, header compression (HPACK), binary framing, and built-in flow control. Protocol Buffers serialization is significantly more compact and faster to encode/decode than JSON for equivalent data structures. In latency-sensitive microservice architectures where thousands of inter-service calls occur per second, these efficiencies compound. Benchmarks from Google and others consistently show gRPC outperforming JSON/HTTP for equivalent payload types, though the gap narrows for large payloads where serialization becomes less dominant.

// users.proto - the interface contract for a user service
syntax = "proto3";

package users.v1;

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
  rpc ListUsers(ListUsersRequest) returns (stream GetUserResponse);
  rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}

message GetUserRequest {
  string user_id = 1;
}

message GetUserResponse {
  string user_id = 1;
  string name    = 2;
  string email   = 3;
  int64  created_at_unix = 4;
}

message ListUsersRequest {
  int32 page_size = 1;
  string page_token = 2;
}

message CreateUserRequest {
  string name  = 1;
  string email = 2;
}

message CreateUserResponse {
  string user_id = 1;
}

The .proto file is the single source of truth. Running protoc against it generates client stubs and server interfaces in Go, Python, TypeScript, Java, or any other supported language. A TypeScript frontend calling a Go backend over gRPC shares the same generated types from the same schema. This is a different category of developer experience from manually keeping OpenAPI specs in sync with JSON API implementations.

tRPC: RPC for TypeScript Monorepos

tRPC takes the RPC model to its logical extreme for TypeScript-first teams. There is no IDL, no code generation step, and no separate schema file. The server defines procedures as plain TypeScript functions, and the client gets full type safety through TypeScript's inference. The server's router definition is the schema.

// server/router.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";

const t = initTRPC.create();

export const appRouter = t.router({
  user: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string().uuid() }))
      .query(async ({ input, ctx }) => {
        const user = await ctx.db.users.findUnique({
          where: { id: input.id },
        });
        if (!user) {
          throw new TRPCError({
            code: "NOT_FOUND",
            message: `User ${input.id} not found`,
          });
        }
        return user;
      }),

    create: t.procedure
      .input(
        z.object({
          name: z.string().min(1).max(100),
          email: z.string().email(),
        }),
      )
      .mutation(async ({ input, ctx }) => {
        return ctx.db.users.create({ data: input });
      }),
  }),
});

export type AppRouter = typeof appRouter;
// client/user.ts - full type inference, no manual typing
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import type { AppRouter } from "../server/router";

const trpc = createTRPCProxyClient<AppRouter>({
  links: [httpBatchLink({ url: "/api/trpc" })],
});

// TypeScript knows the exact shape of the returned user
const user = await trpc.user.getById.query({
  id: "550e8400-e29b-41d4-a716-446655440000",
});
console.log(user.name); // fully typed

// TypeScript catches wrong argument types at compile time
const newUser = await trpc.user.create.mutate({
  name: "Alice",
  email: "alice@example.com",
});

The tradeoff is portability. tRPC is TypeScript-only by design. If you ever need a Python service or a mobile client consuming the same API, you need to expose a separate HTTP interface or switch to gRPC. For teams working in a TypeScript monorepo with no cross-language requirements, this tradeoff is often worth making.

WebSockets: Persistent Bidirectional Channels

The WebSocket Handshake and Connection Model

WebSockets are fundamentally different from HTTP and RPC in their connection model. Where HTTP is stateless and connection-per-request (or connection-pooled but logically stateless), a WebSocket is a persistent, stateful, bidirectional connection. Once established, either side can send messages at any time, without the other side initiating a request first.

A WebSocket connection begins as an HTTP request - the upgrade handshake. The client sends an HTTP/1.1 request with Upgrade: websocket and Connection: Upgrade headers, plus a Sec-WebSocket-Key for handshake verification. The server responds with 101 Switching Protocols, and from that point the TCP connection is repurposed as a WebSocket channel. This design means WebSockets traverse firewalls and proxies that understand HTTP, using standard ports 80 and 443 (WSS for encrypted), without needing special firewall rules.

After the upgrade, the protocol switches to a lightweight binary framing format. Messages are broken into frames, each with a small header containing the opcode (text, binary, ping, pong, close), a payload length, and optionally a masking key (client-to-server messages are masked to prevent cache poisoning by intermediaries). The application-level messages are just byte streams - there is no built-in message schema, no type system, and no standard serialization. You bring your own format: JSON, MessagePack, Protobuf, or anything else.

What WebSockets Enable That HTTP Cannot

The key capability that WebSockets unlock is server-initiated push without polling. In a traditional HTTP architecture, if the server has new information for the client (a message arrived, a job completed, a stock price changed), it cannot send that information unprompted. The client must poll: sending a GET request every N seconds to check for updates. Polling is wasteful, introduces latency proportional to the polling interval, and scales poorly when many clients poll frequently.

WebSockets eliminate polling for these use cases. The server maintains an open connection to each connected client and pushes data the moment it is available. This is not just a latency improvement - it is a qualitative change in system behavior. A collaborative document editor using WebSockets can propagate a keystroke from one user to another in under 50ms on a good network. The same system implemented with 1-second polling would feel broken for collaborative use, regardless of how well the rest of the stack is implemented.

The bidirectionality also matters. Consider a multiplayer game: the client needs to send player input at 60 frames per second, and the server needs to send game state updates back. With HTTP, each of those 60 client messages would be a separate HTTP request with full header overhead. With WebSockets, both directions flow over the same persistent connection with minimal framing overhead. At 60 messages per second, even the difference between a 200-byte HTTP header and a 6-byte WebSocket frame header adds up.

Building a Production WebSocket Server

The following example shows a WebSocket server in Node.js using the ws library, implementing a realistic pattern: authenticated connections, room-based broadcasting, and proper connection lifecycle management.

import WebSocket, { WebSocketServer } from "ws";
import http from "http";
import { verifyAuthToken, type AuthenticatedUser } from "./auth";

interface AuthenticatedSocket extends WebSocket {
  userId: string;
  roomId: string | null;
  isAlive: boolean;
}

const httpServer = http.createServer();
const wss = new WebSocketServer({ server: httpServer });

// Track rooms: roomId -> set of sockets
const rooms = new Map<string, Set<AuthenticatedSocket>>();

wss.on("connection", async (ws: AuthenticatedSocket, req) => {
  // Authenticate on connect - reject unauthenticated connections immediately
  const token = new URL(req.url!, `http://localhost`).searchParams.get("token");
  if (!token) {
    ws.close(4001, "Missing authentication token");
    return;
  }

  let user: AuthenticatedUser;
  try {
    user = await verifyAuthToken(token);
  } catch {
    ws.close(4003, "Invalid or expired token");
    return;
  }

  ws.userId = user.id;
  ws.roomId = null;
  ws.isAlive = true;

  ws.on("pong", () => {
    ws.isAlive = true;
  });

  ws.on("message", (data: Buffer) => {
    let message: { type: string; payload: unknown };
    try {
      message = JSON.parse(data.toString());
    } catch {
      ws.send(JSON.stringify({ type: "error", payload: "Invalid JSON" }));
      return;
    }

    switch (message.type) {
      case "join_room": {
        const roomId = message.payload as string;
        // Leave current room
        if (ws.roomId && rooms.has(ws.roomId)) {
          rooms.get(ws.roomId)!.delete(ws);
        }
        // Join new room
        if (!rooms.has(roomId)) rooms.set(roomId, new Set());
        rooms.get(roomId)!.add(ws);
        ws.roomId = roomId;
        ws.send(JSON.stringify({ type: "joined", payload: { roomId } }));
        break;
      }

      case "broadcast": {
        if (!ws.roomId) {
          ws.send(JSON.stringify({ type: "error", payload: "Not in a room" }));
          return;
        }
        const room = rooms.get(ws.roomId);
        if (!room) return;

        const outbound = JSON.stringify({
          type: "message",
          payload: { from: ws.userId, data: message.payload },
        });

        for (const peer of room) {
          if (peer !== ws && peer.readyState === WebSocket.OPEN) {
            peer.send(outbound);
          }
        }
        break;
      }
    }
  });

  ws.on("close", () => {
    if (ws.roomId && rooms.has(ws.roomId)) {
      rooms.get(ws.roomId)!.delete(ws);
      if (rooms.get(ws.roomId)!.size === 0) {
        rooms.delete(ws.roomId);
      }
    }
  });
});

// Heartbeat: detect dead connections that did not send a close frame
const heartbeat = setInterval(() => {
  for (const ws of wss.clients as Set<AuthenticatedSocket>) {
    if (!ws.isAlive) {
      ws.terminate();
      return;
    }
    ws.isAlive = false;
    ws.ping();
  }
}, 30_000);

wss.on("close", () => clearInterval(heartbeat));

httpServer.listen(3000, () => console.log("WebSocket server on :3000"));

Several production realities are visible in this example. First, authentication must happen at connection time - you cannot authenticate per-message without adding significant overhead. Second, dead connection detection requires an explicit heartbeat mechanism (ping/pong); TCP does not reliably detect a dead peer in time for application-level purposes. Third, room-based fan-out requires maintaining server-side state per connection. This statefulness is what makes WebSocket servers harder to scale horizontally than stateless HTTP servers.

Deep Technical Comparison

Connection Lifecycle

The connection lifecycle differs fundamentally across the three protocols and drives many of the practical tradeoffs.

HTTP connections are inherently transient at the application layer. Even with HTTP keep-alive or HTTP/2 multiplexing, the application treats each request as independent. The server holds no per-client state between requests. This makes HTTP servers easy to scale: add more instances, route any request to any instance, terminate idle connections aggressively.

gRPC connections are long-lived TCP connections (HTTP/2 streams), but they are multiplexed and logically stateless per-call. Multiple concurrent RPC calls can share a single underlying connection. The connection is a transport optimization, not a stateful channel. The server does not need to know which connection a previous call came from. This gives gRPC most of the performance benefits of persistent connections while retaining most of the scalability benefits of statelessness.

WebSocket connections are explicitly stateful. The server must maintain a data structure tracking each connected client, their subscriptions or room memberships, and their authentication context. When a WebSocket server receives a message, it needs to look up the connection's state to determine how to handle it. This state must either live in memory on a single server (making horizontal scaling hard) or be synchronized to shared external storage (adding latency). This is why WebSocket deployments typically require sticky sessions on their load balancer or a pub/sub backend like Redis that all server instances subscribe to.

Serialization and Type Safety

HTTP REST APIs typically use JSON, which is self-describing, human-readable, and universally supported but verbose and without a schema enforced at the protocol level. OpenAPI/Swagger provides a schema layer on top, but it is a documentation convention rather than a runtime contract. Nothing prevents a server from returning a response that violates its own OpenAPI spec. Type mismatches surface at runtime in clients.

gRPC uses Protocol Buffers by default, which is a binary format with a compiler-enforced schema. Fields have explicit numbers, types are strict, and the proto compiler rejects invalid messages before they ever touch the network. This strictness means schema evolution requires discipline (never reuse a field number, mark removed fields as reserved) but pays off in reliability. You cannot accidentally send a string where an integer is expected - the generated code prevents it.

tRPC enforces schema at the framework level using Zod validators on inputs and TypeScript inference on outputs. If the input doesn't match the Zod schema, the procedure never executes - the error is returned before your handler runs. Output types are inferred from the TypeScript return type of the handler, which means they are accurate as long as the handler is correctly typed. Unlike gRPC, nothing validates the output against the schema at runtime unless you explicitly add output validation.

WebSockets are schema-agnostic. The protocol transmits byte frames. Any schema enforcement is purely application-level. Teams typically pick a message envelope format (a JSON object with a type string and a payload) and build their own validation on top of it. This flexibility is useful but means more manual work compared to gRPC's generated code.

Latency Characteristics

Latency in distributed communication has several components: connection establishment, serialization, network transit, and deserialization. The protocols differ most in the first and second components.

HTTP/1.1 with a cold connection requires a TCP handshake before any request data can be sent. With HTTPS, add a TLS handshake. A cold request over HTTPS/1.1 on a 50ms RTT network has at minimum 2-3 RTTs of overhead before the first byte of the request is transmitted. HTTP keep-alive amortizes this over multiple requests. HTTP/2 further reduces it through connection reuse, and HTTP/3 reduces it to a single RTT or even zero RTT for resumed connections. In practice, for internal service communication over a fast LAN, the TCP+TLS overhead is measured in microseconds and rarely matters. Over the public internet to a user's browser, it matters considerably.

gRPC over HTTP/2 on an established connection has very low framing overhead - the HTTP/2 binary frames are compact, and Protocol Buffers encoding is fast. For high-frequency inter-service calls (thousands per second per connection), gRPC's efficiency over an established connection is meaningful. The connection establishment cost is amortized over the connection's lifetime.

WebSockets have a one-time connection establishment cost (the HTTP upgrade handshake), after which message framing overhead is minimal - a 2-byte header for small messages, up to 10 bytes for larger ones. For use cases involving many messages per second (game state, collaborative editing, telemetry streaming), this low per-message overhead is significant.

Implementation Patterns Across All Three

The following example shows the same operation - subscribing to real-time updates for an entity - implemented in all three paradigms, to make the tradeoffs concrete.

Polling with HTTP

// HTTP polling: client checks for updates every 2 seconds
class HttpPoller {
  private intervalId: ReturnType<typeof setInterval> | null = null;
  private lastUpdatedAt: number = 0;

  start(entityId: string, onUpdate: (data: unknown) => void) {
    this.intervalId = setInterval(async () => {
      try {
        const response = await fetch(
          `/api/entities/${entityId}/updates?since=${this.lastUpdatedAt}`,
          { headers: { Authorization: `Bearer ${getAccessToken()}` } },
        );

        if (!response.ok) {
          console.error(`Poll failed: ${response.status}`);
          return;
        }

        const updates = (await response.json()) as {
          items: unknown[];
          latestTimestamp: number;
        };

        if (updates.items.length > 0) {
          updates.items.forEach(onUpdate);
          this.lastUpdatedAt = updates.latestTimestamp;
        }
      } catch (err) {
        console.error("Poll error:", err);
      }
    }, 2000);
  }

  stop() {
    if (this.intervalId !== null) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }
}

This works but has a 2-second maximum latency and sends requests even when there is nothing new. At scale, polling adds non-trivial server load that scales with the number of connected clients, not with the rate of actual updates.

gRPC Server Streaming

// gRPC server streaming: server pushes updates as they occur
import * as grpc from "@grpc/grpc-js";
import { EntityServiceClient } from "./generated/entity_grpc_pb";
import { SubscribeRequest } from "./generated/entity_pb";

function subscribeToEntityUpdates(
  client: EntityServiceClient,
  entityId: string,
  onUpdate: (update: EntityUpdate) => void,
  onError: (err: Error) => void,
): () => void {
  const request = new SubscribeRequest();
  request.setEntityId(entityId);

  const deadline = new Date();
  deadline.setMinutes(deadline.getMinutes() + 30);

  const stream = client.subscribeToUpdates(request, {
    deadline,
    // Propagate auth metadata
    metadata: buildAuthMetadata(),
  });

  stream.on("data", (update: EntityUpdate) => {
    onUpdate(update);
  });

  stream.on("error", (err: grpc.ServiceError) => {
    if (err.code === grpc.status.CANCELLED) return; // Client-initiated cancel
    onError(new Error(`Stream error: ${err.message} (code: ${err.code})`));
  });

  stream.on("end", () => {
    // Server closed the stream - may need to reconnect
    onError(new Error("Stream ended unexpectedly"));
  });

  // Return a cancel function
  return () => stream.cancel();
}

The gRPC approach eliminates polling and delivers updates with the latency of the network path rather than the polling interval. The server streams updates as they occur. The client has a structured error type and a cancel function. This is cleaner than polling and more structured than raw WebSockets.

WebSockets for Bidirectional State Sync

// WebSocket client with reconnection logic and message type safety
type MessageType =
  | { type: "entity_update"; payload: EntityUpdate }
  | { type: "cursor_move"; payload: CursorPosition }
  | { type: "error"; payload: { code: string; message: string } };

class EntitySyncClient {
  private ws: WebSocket | null = null;
  private reconnectDelay = 1000;
  private maxReconnectDelay = 30_000;
  private handlers = new Map<string, ((payload: unknown) => void)[]>();

  constructor(
    private readonly url: string,
    private readonly entityId: string,
  ) {}

  connect() {
    this.ws = new WebSocket(`${this.url}?token=${getAccessToken()}`);

    this.ws.onopen = () => {
      this.reconnectDelay = 1000; // Reset backoff on successful connect
      this.ws!.send(
        JSON.stringify({ type: "subscribe", payload: this.entityId }),
      );
    };

    this.ws.onmessage = (event) => {
      let message: MessageType;
      try {
        message = JSON.parse(event.data);
      } catch {
        console.error("Received invalid JSON from server");
        return;
      }
      const handlers = this.handlers.get(message.type) ?? [];
      handlers.forEach((h) => h(message.payload));
    };

    this.ws.onclose = (event) => {
      if (!event.wasClean) {
        // Reconnect with exponential backoff
        setTimeout(() => {
          this.reconnectDelay = Math.min(
            this.reconnectDelay * 2,
            this.maxReconnectDelay,
          );
          this.connect();
        }, this.reconnectDelay);
      }
    };

    this.ws.onerror = (event) => {
      console.error("WebSocket error:", event);
    };
  }

  on(type: MessageType["type"], handler: (payload: unknown) => void) {
    if (!this.handlers.has(type)) this.handlers.set(type, []);
    this.handlers.get(type)!.push(handler);
  }

  sendCursorPosition(position: CursorPosition) {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ type: "cursor_move", payload: position }));
    }
  }

  disconnect() {
    this.ws?.close(1000, "Client disconnect");
  }
}

The WebSocket version handles the bidirectional requirement naturally: the client can send cursor position updates at any time while also receiving entity updates from the server. The reconnection logic with exponential backoff is essential - it is not optional for production WebSocket clients.

Trade-offs and Pitfalls

HTTP: When the Resource Model Breaks Down

The most common pitfall with HTTP APIs is forcing procedural operations into a resource model. Operations like "lock a record", "trigger a workflow", "retry a failed job", or "validate and preview a draft" do not map cleanly to CRUD verbs on resources. The resulting endpoints become awkward: POST /records/42/locks, POST /workflows/trigger, POST /jobs/99/retry. These work, but they require consumers to understand your resource naming conventions rather than reading a procedure list.

HTTP also struggles with operations that return large datasets or operate over long durations. A synchronous HTTP response must complete within the server's timeout window (often 30-60 seconds). Long-running operations need to be made asynchronous - POST the operation, receive a job ID, poll for status - which adds client complexity. gRPC's streaming or WebSockets handle the duration problem more cleanly.

HTTP caching is underused by most teams. GET requests with proper Cache-Control, ETag, and Last-Modified headers can eliminate server load for read-heavy APIs. Most teams ignore this because caching requires coordinating across the backend that generates the response and the infrastructure that serves it. This is a missed optimization, not an inherent HTTP limitation.

gRPC: Operational and Ecosystem Friction

gRPC's main friction points are tooling and ecosystem maturity outside of Go and Java. Browser clients cannot call gRPC directly - the browser's Fetch API cannot set HTTP/2 trailers, which gRPC requires for status codes. The solution is gRPC-Web, a different wire format that gRPC proxies (like Envoy or grpc-web) translate to native gRPC on the backend. This adds an infrastructure component that a plain HTTP API does not require.

Proto file management in large organizations is also a meaningful operational cost. Where does the canonical .proto file live? How do teams share it? How do you enforce compatibility policies? Large companies (Google, Uber, Lyft) solve this with dedicated schema registries and automated compatibility checking. Smaller teams often underinvest here and end up with .proto files copy-pasted across repos, diverging over time.

The debugging experience for gRPC, while improving, remains less accessible than HTTP. You cannot inspect gRPC traffic with a basic browser DevTools network panel. Tools like grpcurl and Postman's gRPC support help, but they require setup that curl does not.

WebSockets: Statefulness and Scale

WebSocket servers are stateful by nature, and this is the source of most of their operational complexity. A WebSocket connection to server A cannot be continued on server B - the connection is a specific TCP socket. Load balancers must use sticky sessions (source IP or cookie-based affinity) to route reconnecting clients back to the same server. This breaks the simple horizontal scaling model that HTTP allows.

For multi-node WebSocket deployments, the standard architecture is a shared pub/sub backend - Redis Pub/Sub is the most common choice - that all server nodes subscribe to. When a message needs to be broadcast to clients connected to different nodes, one node publishes to Redis and all nodes with subscribers in the target set deliver it. This architecture works but adds Redis as a required dependency and makes the broadcast path dependent on Redis performance.

Connection limits are also a real concern. Each WebSocket connection holds an open TCP socket on the server, consuming file descriptor budget and memory. A single Node.js process can handle tens of thousands of WebSocket connections under appropriate tuning, but capacity planning for WebSocket servers requires more care than for stateless HTTP servers where connections are transient.

Best Practices

Match Protocol to Communication Pattern First

Before choosing a framework or library, determine which communication patterns your system actually requires. For each interface your service exposes, ask: is this request-response? Does the server ever need to push data without being asked? Is there bidirectional real-time exchange? The answers to these questions should drive the protocol choice, not the team's existing familiarity or the framework that was used last time. Mixing patterns in the same system - HTTP for CRUD, gRPC for service-to-service calls, WebSockets for real-time features - is normal and appropriate. Trying to force all patterns into a single protocol is where engineers get into trouble.

Use gRPC for Internal Service-to-Service Communication

In microservice architectures, gRPC is generally the right default for synchronous service-to-service calls. The schema enforcement prevents a class of integration bugs that JSON APIs allow. The generated client code eliminates manual HTTP client setup. The HTTP/2 transport is more efficient for high-frequency calls. The streaming support handles use cases that HTTP handles awkwardly. The operational overhead is real but lower than it appears once the initial toolchain setup is complete.

Invest in WebSocket Infrastructure Before You Need It

WebSocket deployments require infrastructure that HTTP deployments do not: sticky sessions on the load balancer, a pub/sub backend for multi-node fan-out, a heartbeat mechanism to detect dead connections, and reconnection logic in every client. Teams that build WebSocket features without anticipating this infrastructure end up retrofitting it under pressure after the first production incident. Plan for Redis Pub/Sub, configure sticky sessions in the load balancer before launch, and write the reconnection logic as part of the initial client implementation rather than as a follow-up.

Expose HTTP/REST for External and Third-Party Consumers

Whatever your internal service communication protocol, external-facing APIs should almost always be HTTP/REST with JSON. Third-party developers, mobile clients, and public integrations expect HTTP. The debugging and testing experience is lower friction. The client library landscape is richer. Even if your internal services communicate over gRPC, use a gateway (gRPC-Gateway, Envoy, or custom translation layer) to expose HTTP to the outside world. Do not force external consumers to set up gRPC toolchains.

Instrument All Three Protocols Consistently

Observability requirements are identical regardless of the protocol: trace context should propagate through every RPC call or WebSocket message exchange; metrics for latency, error rate, and throughput should be emitted; structured logs should capture correlation IDs. gRPC integrates with OpenTelemetry directly. HTTP APIs can propagate traceparent headers (W3C Trace Context standard). WebSocket applications need to pass trace context in message envelopes, not in connection headers, since the connection is established once but messages carry the per-operation context.

Key Takeaways

Five things you can act on immediately:

  1. Audit your current service interfaces for protocol mismatch. For each interface, classify the communication pattern (request-response, server push, bidirectional). If you are using HTTP polling where you need sub-second server push, you have a protocol mismatch worth addressing.

  2. Adopt gRPC for any new internal microservice interface you build. The schema contract and generated code will pay dividends in maintainability. Start with the .proto file, not the implementation.

  3. Add heartbeat and reconnection logic to every WebSocket client you ship. If your WebSocket client does not handle disconnection with exponential backoff, it is not production-ready. This is table stakes, not an optimization.

  4. Configure sticky sessions on your load balancer before deploying WebSocket services. This is not something you can safely retrofit under traffic. Make it part of the initial deployment configuration.

  5. Never call a remote procedure in a loop without evaluating batching. Whether it is HTTP, gRPC, or a WebSocket message stream, per-item remote calls in a loop are a latency multiplier. Design batch endpoints and use them.

80/20 Insight

Most of the confusion around HTTP vs RPC vs WebSockets collapses into two questions:

Who initiates the communication, and how often?

If the client always initiates and requests are infrequent-to-moderate: HTTP or RPC. If the server needs to push, or if communication is continuous and high-frequency: WebSockets (or gRPC streaming). If you are in the middle - infrequent server push, acceptable latency - Server-Sent Events over HTTP is often the underrated option: it is unidirectional, HTTP-native, automatically reconnects, and does not require the operational overhead of WebSockets.

Is schema enforcement worth the overhead?

If yes - and it usually is for anything beyond prototyping - RPC (gRPC or tRPC) gives you machine-readable contracts, generated code, and compile-time safety at a cost of tooling setup and reduced ecosystem universality. HTTP REST with OpenAPI gives you a schema layer with broader ecosystem support but weaker runtime enforcement. WebSockets with JSON give you maximum flexibility with zero schema enforcement by default.

Get these two dimensions right and the rest of the decision largely follows.

Analogies and Mental Models

HTTP as a Postal Service

HTTP request-response is like sending a letter. You write the letter (the request), address it (the URL), include any context the recipient needs (headers, body), seal it, and send it. The postal service delivers it, the recipient reads it, writes a response, and sends it back. The postal service does not care about the relationship between sender and recipient. There is no open channel - each letter is independent. This model scales to billions of letters because no channel needs to be maintained between them.

gRPC as a Phone Call to a Company's IVR System

Calling a business's phone system, navigating a menu ("press 1 for sales, press 2 for support"), and waiting for a response is like an RPC call. The menu is the IDL - it tells you what operations are available and what inputs each one requires. The operator handling your call is the server skeleton that dispatches your request to the right handler. When the call completes, the channel closes. The procedure orientation is explicit: you are not "accessing a resource," you are "doing a thing" with an explicit contract about what you need to say and what you will hear back.

WebSockets as a Phone Call Between Two People

A WebSocket connection is like an open phone call. Once connected, both sides can speak at any time without the other saying "go ahead." You can interrupt, overlap, and respond continuously. The connection is stateful - both parties know they are on a call with each other - and bidirectional. But if the line drops, you need to call back and re-establish the conversation, and neither side automatically knows the other's current state without re-exchanging it.

Conclusion

HTTP, RPC, and WebSockets are not competing answers to the same question. They are answers to different questions about how components should communicate. HTTP's stateless request-response model is the right answer for most resource-oriented, client-initiated interactions - especially across organizational boundaries or with third-party consumers. RPC's procedure-oriented model with schema-enforced contracts is the right answer for internal service-to-service communication where type safety, efficiency, and evolutionary stability matter. WebSockets are the right answer when the communication is persistent, bidirectional, or server-initiated in a way that polling cannot adequately serve.

The failure mode in most systems is not choosing the wrong protocol for a use case but rather failing to recognize when the chosen protocol has become a mismatch as the use case evolved. A system designed for infrequent batch operations that now needs real-time collaboration has outgrown HTTP polling. A monolith being decomposed into services will benefit from RPC contracts where it previously had direct function calls. Recognizing these transitions and making the protocol change before the operational pain becomes acute is one of the distinguishing capabilities of experienced distributed systems engineers.

Use HTTP broadly. Use gRPC for service contracts. Use WebSockets when the communication model demands it. And understand all three well enough to recognize when you need to change.

References

  1. Fielding, R. T. (2000). Architectural Styles and the Design of Network-based Software Architectures (Doctoral dissertation, University of California, Irvine). https://ics.uci.edu/~fielding/pubs/dissertation/top.htm
  2. Fette, I., & Melnikov, A. (2011). The WebSocket Protocol (RFC 6455). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc6455
  3. Belshe, M., Peon, R., & Thomson, M. (2015). Hypertext Transfer Protocol Version 2 (HTTP/2) (RFC 7540). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc7540
  4. Bishop, M. (2022). HTTP/3 (RFC 9114). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc9114
  5. Google. gRPC Documentation - Core Concepts. https://grpc.io/docs/what-is-grpc/core-concepts/
  6. Google. Protocol Buffers Language Guide (proto3). https://protobuf.dev/programming-guides/proto3/
  7. tRPC. tRPC Documentation. https://trpc.io/docs/
  8. Kleppmann, M. (2017). Designing Data-Intensive Applications. O'Reilly Media. (Chapter 4: Encoding and Evolution; Chapter 12: The Future of Data Systems.)
  9. Nottingham, M. (2012). HTTP/1.1 Persistent Connections (RFC 7230, Section 6). Internet Engineering Task Force. https://www.rfc-editor.org/rfc/rfc7230
  10. W3C. Server-Sent Events Specification. https://html.spec.whatwg.org/multipage/server-sent-events.html
  11. OpenTelemetry. W3C Trace Context Propagation. https://www.w3.org/TR/trace-context/
  12. Redis. Redis Pub/Sub Documentation. https://redis.io/docs/manual/pubsub/
  13. Tanenbaum, A. S., & Van Steen, M. (2017). Distributed Systems: Principles and Paradigms (3rd ed.). https://www.distributed-systems.net/