Showing posts with label software-engineering. Show all posts
Showing posts with label software-engineering. Show all posts

Friday, April 17, 2026

WebSockets and Real-Time Architecture in 2026: SSE, WebRTC, and Scaling Stateful Connections

Hero image

Introduction

The web was designed for request-response. A client asks, a server answers, the connection closes. That model works for loading pages, submitting forms, and fetching data on demand. It falls apart the moment you need the server to push something to the client without being asked — a new chat message arriving, a collaborator's cursor moving, a stock price updating, a live game state changing.

In 2026, real-time is no longer a niche feature. Chat is table stakes. Collaborative editing is expected — Google Docs set that bar a decade ago and every modern SaaS has internalized it. Live dashboards are standard in observability tools, trading platforms, and operational software. Multiplayer experiences, from document editors to CAD tools to coding environments, have moved from differentiators to requirements. Presence indicators — knowing who else is in the document, who is typing, who is online — are woven into every serious collaborative product.

The technical challenge is that none of this fits HTTP's request-response model natively. Three transport protocols have emerged to solve it, each with different tradeoffs: WebSockets, Server-Sent Events (SSE), and WebRTC. Choosing the wrong one creates architectural debt that is painful to unwind. Using WebSockets everywhere is as much a mistake as never using them.

WebSockets give you a full-duplex persistent connection — both sides can send at any time. SSE gives you a one-way stream from server to client over plain HTTP, with built-in reconnection and event replay. WebRTC gives you peer-to-peer connections for media and data, bypassing your servers entirely for the data path. Each occupies a different position in the design space.

The practical decision comes down to directionality, frequency, latency requirements, and infrastructure complexity. A live notification feed doesn't need WebSockets — SSE is simpler, more reliable, and scales better. A video call doesn't belong on WebSockets — WebRTC is the right tool. A multiplayer game or collaborative editor genuinely needs WebSockets or a higher-level abstraction like CRDTs on top of them.

This post covers each transport in depth, with complete working code, and then addresses the hardest production problem: scaling stateful connections horizontally across multiple server instances.


1. WebSockets: Full-Duplex Persistent Connections

WebSockets are the most versatile of the three transport options, and consequently the most overused. Understanding the protocol mechanics first makes it easier to know when to reach for it and when to leave it on the shelf.

The Handshake: HTTP Upgrade

A WebSocket connection starts as a plain HTTP request. The client sends an Upgrade header signaling that it wants to switch protocols:

GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

If the server agrees, it responds with 101 Switching Protocols. From that point on, the TCP connection is handed over to the WebSocket protocol — both sides can send frames independently at any time. There is no polling, no long-hanging request, no re-establishing a connection for each message.

The Sec-WebSocket-Key mechanism is a base64-encoded 16-byte nonce. The server concatenates it with a fixed GUID, SHA-1 hashes the result, and base64-encodes it back. This prevents a misconfigured HTTP cache from treating WebSocket frames as HTTP responses. It is not security — it is a protocol handshake validity check.

Frame Types

The WebSocket frame format is lean. Each frame has a 2-byte minimum header containing:
- FIN bit: whether this is the final frame in a message (messages can be fragmented)
- Opcode: what kind of frame this is
- Masking bit: client→server frames must be masked (server→client must not be)
- Payload length

The opcodes you care about in practice:
- 0x1 — text frame (UTF-8 payload)
- 0x2 — binary frame (arbitrary bytes)
- 0x8 — close frame (with optional status code and reason)
- 0x9 — ping frame (keepalive, expects pong)
- 0xA — pong frame (response to ping)

For most application-level messaging you'll use text frames with JSON payloads. For high-throughput binary protocols — game state sync, sensor streams, audio chunks — binary frames with MessagePack or Protocol Buffers reduce payload size substantially compared to JSON.

Node.js WebSocket Server: Rooms and Broadcast

The ws library is the standard low-level WebSocket implementation for Node.js. Here is a complete server with room-based broadcasting — the pattern you need for chat, presence, and any multi-tenant real-time feature:

import { WebSocketServer, WebSocket } from "ws";
import { createServer } from "http";
import { parse } from "url";

interface Client {
  ws: WebSocket;
  userId: string;
  room: string;
}

// Map from roomId → Set of connected clients in that room
const rooms = new Map<string, Set<Client>>();

const server = createServer();
const wss = new WebSocketServer({ server });

wss.on("connection", (ws: WebSocket, req) => {
  const { query } = parse(req.url ?? "", true);
  const userId = String(query.userId ?? "anonymous");
  const room = String(query.room ?? "default");

  // Validate JWT or session token here before proceeding
  // If auth fails: ws.close(4001, "Unauthorized"); return;

  const client: Client = { ws, userId, room };

  // Add client to room
  if (!rooms.has(room)) rooms.set(room, new Set());
  rooms.get(room)!.add(client);

  console.log(`[${room}] ${userId} connected. Room size: ${rooms.get(room)!.size}`);

  // Notify others in the room of the new presence
  broadcastToRoom(room, { type: "presence", userId, event: "joined" }, client);

  // Heartbeat: detect dead connections that didn't send a close frame
  // (common with mobile networks, NAT timeouts, browser tab crashes)
  let isAlive = true;
  ws.on("pong", () => { isAlive = true; });

  const heartbeatInterval = setInterval(() => {
    if (!isAlive) {
      // No pong received — connection is dead, terminate it
      console.warn(`[${room}] ${userId} heartbeat timeout, terminating`);
      ws.terminate();
      return;
    }
    isAlive = false;
    ws.ping(); // Send ping, expect pong back within next interval
  }, 30_000); // 30-second heartbeat interval

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

    // Route by message type
    switch (message.type) {
      case "chat":
        broadcastToRoom(room, {
          type: "chat",
          userId,
          text: message.text,
          ts: Date.now(),
        });
        break;

      case "ping":
        // Application-level ping (distinct from WebSocket protocol ping)
        ws.send(JSON.stringify({ type: "pong", ts: Date.now() }));
        break;

      default:
        ws.send(JSON.stringify({ error: "unknown message type" }));
    }
  });

  ws.on("close", () => {
    clearInterval(heartbeatInterval);
    rooms.get(room)?.delete(client);
    if (rooms.get(room)?.size === 0) rooms.delete(room);
    broadcastToRoom(room, { type: "presence", userId, event: "left" });
    console.log(`[${room}] ${userId} disconnected`);
  });

  ws.on("error", (err) => {
    console.error(`[${room}] ${userId} error:`, err.message);
    clearInterval(heartbeatInterval);
    rooms.get(room)?.delete(client);
  });

  // Send initial room state to the newly connected client
  ws.send(JSON.stringify({
    type: "init",
    room,
    members: [...(rooms.get(room) ?? [])].map(c => c.userId),
  }));
});

function broadcastToRoom(
  room: string,
  message: Record<string, unknown>,
  exclude?: Client
): void {
  const clients = rooms.get(room);
  if (!clients) return;
  const payload = JSON.stringify(message);
  for (const client of clients) {
    // Skip the sender if excluded, and skip any connection not in OPEN state
    if (client === exclude) continue;
    if (client.ws.readyState === WebSocket.OPEN) {
      client.ws.send(payload);
    }
  }
}

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

Client Reconnection with Exponential Backoff

Connections drop. Mobile networks switch, laptops sleep, browsers navigate. A production client must reconnect automatically:

class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private attempt = 0;
  private readonly maxDelay = 30_000; // cap at 30 seconds
  private readonly baseDelay = 500;   // start at 500ms

  constructor(
    private readonly url: string,
    private readonly onMessage: (data: unknown) => void
  ) {
    this.connect();
  }

  private connect(): void {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log("Connected");
      this.attempt = 0; // reset backoff on successful connect
    };

    this.ws.onmessage = (event) => {
      try {
        this.onMessage(JSON.parse(event.data));
      } catch {
        console.warn("Non-JSON message received:", event.data);
      }
    };

    this.ws.onclose = () => {
      const delay = Math.min(
        this.baseDelay * Math.pow(2, this.attempt) + Math.random() * 500,
        this.maxDelay
      );
      this.attempt++;
      console.log(`Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt})`);
      setTimeout(() => this.connect(), delay);
    };

    this.ws.onerror = () => {
      // onclose fires after onerror — let it handle reconnection
      this.ws?.close();
    };
  }

  send(data: unknown): void {
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

The jitter (Math.random() * 500) is critical when you have many clients reconnecting simultaneously after a server restart — without it, the thundering herd hits your server in synchronized waves.

When WebSockets Are Overkill

WebSockets maintain a persistent TCP connection for their entire lifetime. Each connection consumes a file descriptor on the server. With the default ulimit -n on Linux (1024), an untuned server runs out of file descriptors at 1024 simultaneous connections — a completely avoidable problem, but one that illustrates the statefulness cost.

Do not use WebSockets for:
- Low-frequency updates — polling every 30 seconds (stock closing prices, batch job status) costs less than a persistent connection
- One-way data flow — if the client never sends messages back, SSE is simpler and more reliable
- HTTP/2 push scenarios — SSE over HTTP/2 multiplexes multiple streams over one connection at no extra cost

Architecture diagram
sequenceDiagram participant C as Client participant S as WebSocket Server participant R as Room Registry C->>S: GET /chat?room=general&userId=alice (HTTP Upgrade) S->>C: 101 Switching Protocols Note over C,S: TCP connection now WebSocket C->>S: {type: "join", room: "general"} S->>R: Register alice in room "general" R-->>S: Room members: [alice, bob, carol] S->>C: {type: "init", members: ["bob","carol"]} S-->>S: Broadcast {type:"presence", user:"alice", event:"joined"} to bob, carol C->>S: {type: "chat", text: "hello"} S->>R: Lookup "general" members R-->>S: [alice, bob, carol] S->>C: {type: "chat", userId:"alice", text:"hello"} S-->>S: Forward to bob and carol loop Every 30s S->>C: PING (protocol frame) C->>S: PONG (protocol frame) end

2. Server-Sent Events: One-Way Streams

Server-Sent Events are the underused tool in most engineers' real-time toolkit. They solve a specific problem extremely well: the server needs to push a stream of events to the client, but the client does not need to send data back over the same connection.

How SSE Works

SSE uses plain HTTP. The client makes an ordinary GET request, and the server responds with Content-Type: text/event-stream and keeps the connection open, writing newline-delimited events as they occur. There is no new protocol, no handshake, no custom framing — it runs over HTTP/1.1 or HTTP/2 without modification.

The event format is simple text:

id: 42
event: price-update
data: {"symbol":"AAPL","price":213.40,"change":+1.2}

id: 43
event: price-update
data: {"symbol":"GOOG","price":177.85,"change":-0.8}

Each event ends with a blank line. The id field is what makes SSE powerful: when the connection drops and the EventSource reconnects, it sends the Last-Event-ID header with the last event ID it received. Your server can use this to replay missed events from a queue or database. Zero message loss with zero application code — the protocol handles it.

Named events (event: price-update) let a single stream carry multiple event types. The client subscribes selectively:

const source = new EventSource("/api/stream/market");

// Listen to specific named events
source.addEventListener("price-update", (event) => {
  const data = JSON.parse(event.data);
  updatePriceDisplay(data.symbol, data.price);
});

source.addEventListener("trade-executed", (event) => {
  const trade = JSON.parse(event.data);
  appendTradeToLog(trade);
});

// Generic message handler for unnamed events
source.onmessage = (event) => {
  console.log("Generic event:", event.data);
};

source.onerror = (err) => {
  // EventSource reconnects automatically — this fires on each retry
  // source.readyState === EventSource.CONNECTING means it's retrying
  console.warn("SSE error, reconnecting...", source.readyState);
};

No library required. EventSource is built into every browser and has been since 2012.

Python FastAPI SSE Endpoint

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio
import json
import time
from typing import AsyncGenerator

app = FastAPI()

# In production, replace with Redis pub/sub or a real event queue
async def market_event_generator(
    request: Request,
    last_event_id: str | None
) -> AsyncGenerator[str, None]:
    """
    Generate SSE-formatted events for market data stream.
    last_event_id allows replay from a specific point.
    """
    event_id = int(last_event_id) + 1 if last_event_id else 1

    # If the client reconnected mid-stream, replay missed events here
    # e.g., fetch events with id > last_event_id from your event store

    while True:
        # Check if the client has disconnected
        if await request.is_disconnected():
            print(f"Client disconnected at event {event_id}")
            break

        # Fetch the next event from your data source
        # Here: simulated market tick
        event_data = {
            "symbol": "AAPL",
            "price": 213.40 + (event_id % 5) * 0.1,
            "ts": time.time(),
        }

        # SSE format: each field on its own line, blank line terminates event
        yield f"id: {event_id}\n"
        yield f"event: price-update\n"
        yield f"data: {json.dumps(event_data)}\n"
        yield "\n"  # blank line = end of event

        event_id += 1
        await asyncio.sleep(1)  # 1-second tick interval


@app.get("/api/stream/market")
async def market_stream(request: Request):
    last_event_id = request.headers.get("Last-Event-ID")

    return StreamingResponse(
        market_event_generator(request, last_event_id),
        media_type="text/event-stream",
        headers={
            # Prevent buffering — critical for SSE to work through proxies
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",  # Disable nginx buffering
            "Connection": "keep-alive",
        },
    )


@app.get("/api/stream/notifications/{user_id}")
async def notification_stream(user_id: str, request: Request):
    """
    Per-user notification stream. In production, subscribe to a Redis
    pub/sub channel keyed by user_id here.
    """
    async def event_generator() -> AsyncGenerator[str, None]:
        # Send a heartbeat comment every 20 seconds to prevent proxy timeout
        # SSE comments start with ':'  — browsers ignore them
        heartbeat_id = 0
        while True:
            if await request.is_disconnected():
                break
            # Heartbeat keeps the connection alive through aggressive proxies
            yield f": heartbeat {heartbeat_id}\n\n"
            heartbeat_id += 1
            await asyncio.sleep(20)

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

SSE over HTTP/2

Under HTTP/1.1, browsers limit connections per host to six. An SSE connection consumes one of those six slots, which can starve other requests on the same domain. Under HTTP/2, all requests share a single multiplexed connection — SSE becomes just another stream on that connection. If your server supports HTTP/2 (nginx with http2 directive, Caddy by default, Cloudflare always), SSE scales substantially better. You can open dozens of SSE streams per tab without connection pressure.

When SSE Beats WebSocket

Use SSE when:
- The client only consumes data (dashboards, notification feeds, live logs, activity streams)
- You want built-in reconnection and event replay without writing reconnect logic
- You are streaming LLM token output to a browser — every major AI product in 2026 uses SSE for this
- You want to run behind a standard HTTP reverse proxy without WebSocket upgrade configuration
- You need to fan out server events to many read-only consumers

sequenceDiagram participant C as Client (EventSource) participant P as Proxy / CDN participant S as FastAPI Server C->>P: GET /api/stream/market (Accept: text/event-stream) P->>S: Forward request S->>P: 200 OK, Content-Type: text/event-stream P->>C: 200 OK (connection held open) loop Every 1s S->>P: id:1\nevent:price-update\ndata:{...}\n\n P->>C: Forward event chunk C->>C: Fire "price-update" event listener end Note over P,C: Network drop / proxy timeout C->>C: EventSource auto-reconnects after 3s C->>P: GET /api/stream/market\nLast-Event-ID: 47 P->>S: Forward with Last-Event-ID: 47 S->>S: Replay events 48+ from queue S->>P: Resume stream from id:48 P->>C: id:48\nevent:price-update\ndata:{...}\n\n

3. WebRTC: Peer-to-Peer Media

WebRTC is a different category entirely. It is not a transport you use for application data under normal circumstances. It exists for one primary reason: moving audio, video, and arbitrary data between browsers with the lowest possible latency, without routing that data through your servers.

The Use Case

When you make a video call on Google Meet, Zoom, or Discord, the video frames are not going from your browser to a server and back to the other person. They travel directly between the two browsers — or through a media relay if direct connection isn't possible. That direct path eliminates a server hop, cuts latency roughly in half, and means your servers don't pay for the bandwidth of transmitting video frames. For a platform like Discord handling 8M+ concurrent voice connections, the bandwidth savings are enormous.

The Signaling Dance

WebRTC connections require a signaling channel to negotiate the connection. The signaling mechanism is intentionally not specified by the WebRTC standard — you can use WebSockets, SSE, HTTP long-polling, or carrier pigeon. In practice, everyone uses WebSockets.

The negotiation has two parts:

Session Description Protocol (SDP) offer/answer: Peer A creates an offer describing its media capabilities (codecs it supports, bandwidth parameters, data channel intent). It sends this to Peer B via the signaling channel. Peer B responds with an answer. Both sides now know what the connection will carry.

ICE candidates: WebRTC uses the Interactive Connectivity Establishment framework to find the best network path between peers. Each browser generates a list of candidate addresses — local IP, reflexive IP from a STUN server, relayed IP from a TURN server — and exchanges them via the signaling channel. The ICE agent tries each pair to find the one with the lowest latency.

// Simplified WebRTC connection setup (both sides follow this pattern)
const pc = new RTCPeerConnection({
  iceServers: [
    { urls: "stun:stun.l.google.com:19302" }, // Free STUN server for NAT traversal
    {
      // TURN relay — required when STUN fails (symmetric NAT, firewalls)
      // You must run your own or use a paid service (Twilio, Cloudflare Calls)
      urls: "turn:turn.example.com:3478",
      username: "user",
      credential: "pass",
    },
  ],
});

// For data channels (arbitrary P2P data, no media required)
const dataChannel = pc.createDataChannel("game-state", {
  ordered: false,    // UDP-like: drop stale packets rather than wait for retransmit
  maxRetransmits: 0, // For game state: latest frame wins, don't retransmit old ones
});

dataChannel.onmessage = (event) => {
  const state = JSON.parse(event.data);
  applyGameState(state);
};

// Trickle ICE: send candidates as they're discovered, don't wait for all of them
pc.onicecandidate = (event) => {
  if (event.candidate) {
    signalingChannel.send({ type: "ice-candidate", candidate: event.candidate });
  }
};

// Caller side: create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: "offer", sdp: offer.sdp });

// Callee side: receive offer, create and send answer
signalingChannel.onmessage = async (msg) => {
  if (msg.type === "offer") {
    await pc.setRemoteDescription(new RTCSessionDescription(msg));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    signalingChannel.send({ type: "answer", sdp: answer.sdp });
  }
  if (msg.type === "ice-candidate") {
    await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
  }
};

STUN vs TURN

STUN (Session Traversal Utilities for NAT) is a lightweight server that tells a browser its own public IP address. It's how the browser discovers its reflexive candidate. STUN servers are cheap to run and have free public instances. About 80% of WebRTC connections succeed with STUN alone.

TURN (Traversal Using Relays around NAT) is a relay server. For the other 20% — symmetric NATs, enterprise firewalls — direct P2P is impossible. TURN relays all media between the peers through the server. This is bandwidth-intensive: you pay for every byte of video. Cloudflare Calls and Twilio provide TURN as a service. If you run your own, budget for the bandwidth.

Latency Comparison

Transport Typical Latency Notes
WebRTC data channel 30–80 ms P2P, no server hop in media path
WebSocket 80–200 ms Server round-trip included
SSE 100–300 ms One-way, server push
HTTP polling (1s) 0–1000 ms Depends entirely on poll interval

WebRTC's latency advantage only matters when it matters a lot: video calls, real-time gaming, collaborative cursors. For chat, notifications, and dashboards, WebSocket or SSE latency is imperceptible to humans.

Comparison visual
flowchart TD Start([What do you need?]) --> Q1{Does the client
send data back
to the server?} Q1 -->|No| SSE[Use SSE
Simpler, HTTP-native,
auto-reconnect, event replay] Q1 -->|Yes| Q2{Is media involved
video/audio/low-latency
P2P data?} Q2 -->|Yes| WebRTC[Use WebRTC
P2P, lowest latency,
handles NAT traversal] Q2 -->|No| Q3{Update frequency?} Q3 -->|Low
less than 1/min| Poll[HTTP Polling
Simplest, low overhead] Q3 -->|Medium–High
seconds to ms| Q4{Bidirectional
client and server
both initiate?} Q4 -->|Yes| WS[Use WebSocket
Full-duplex, persistent,
rooms + broadcast] Q4 -->|No — server pushes| SSE2[Use SSE
Unidirectional is enough] style SSE fill:#22c55e,color:#fff style SSE2 fill:#22c55e,color:#fff style WebRTC fill:#3b82f6,color:#fff style WS fill:#f59e0b,color:#fff style Poll fill:#94a3b8,color:#fff

4. Scaling Stateful Connections

A single Node.js process can handle approximately 10,000–20,000 concurrent WebSocket connections, depending on message throughput and per-connection memory usage. At that ceiling — or before it for reliability — you need multiple server instances. This is where real-time architecture gets hard.

The Stickiness Problem

HTTP is stateless. A load balancer can route any request to any backend instance because there is no per-instance state that makes one instance the "right" one for a given client. WebSocket connections are the opposite: once connected, a client is bound to a specific server instance for the duration of that connection. Room membership, subscription lists, and in-flight message buffers all live in that instance's memory.

If a client connects to Instance A, joins room "project-42", and a message arrives for "project-42", it must be delivered through Instance A. Instance B and Instance C don't know the client exists.

Sticky Sessions: Works Until It Doesn't

The simplest approach is IP-hash or cookie-based session affinity at the load balancer. nginx:

upstream websocket_backend {
    ip_hash;  # Route the same client IP to the same upstream
    server ws1.internal:8080;
    server ws2.internal:8080;
    server ws3.internal:8080;
}

This works for small deployments. It breaks down when:
- A server instance restarts — all its connections drop and clients reconnect, potentially to different instances
- IPv6 or CGNAT means many users share one IP (corporate networks)
- You need zero-downtime deploys — draining one instance means redistributing thousands of connections

Redis Pub/Sub: The Correct Solution

The production pattern is to move room state out of process memory and into Redis. Every server instance subscribes to channels in Redis. When a message needs to reach all members of room "project-42", it's published to a Redis channel. Every instance picks it up and delivers it to any local clients subscribed to that room.

Socket.io, the higher-level WebSocket abstraction library, has a first-class Redis adapter for exactly this:

import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: { origin: "https://app.example.com" },
  transports: ["websocket", "polling"], // Polling fallback for restrictive networks
});

// Two Redis clients: one for publishing, one for subscribing
// Redis requires separate clients because SUBSCRIBE puts a connection
// into subscriber mode and it cannot be used for other commands
const pubClient = createClient({ url: "redis://redis.internal:6379" });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

// Wire Socket.io to Redis — now all emit/broadcast calls fan out across instances
io.adapter(createAdapter(pubClient, subClient));

io.on("connection", (socket) => {
  const userId = socket.handshake.auth.userId;
  const room = socket.handshake.query.room as string;

  if (!userId || !room) {
    socket.disconnect(true);
    return;
  }

  // Socket.io rooms are virtual groups. With the Redis adapter,
  // join/leave/emit are automatically synchronized across all instances.
  socket.join(room);
  console.log(`${userId} joined room ${room} on instance ${process.pid}`);

  // This emit reaches ALL clients in the room on ALL server instances
  io.to(room).emit("presence", { userId, event: "joined" });

  socket.on("chat", (text: string) => {
    // Validate and sanitize before broadcasting
    if (typeof text !== "string" || text.length > 1000) return;

    io.to(room).emit("chat", {
      userId,
      text: text.trim(),
      ts: Date.now(),
    });
  });

  socket.on("disconnecting", () => {
    io.to(room).emit("presence", { userId, event: "left" });
  });
});

httpServer.listen(8080, () => {
  console.log(`Instance ${process.pid} listening on :8080`);
});

With this setup, a client connected to Instance A can send a message that is delivered to a client on Instance C — via Redis pub/sub in approximately one additional millisecond of latency.

Kubernetes Considerations

In Kubernetes, WebSocket-backed deployments require deliberate configuration:

Ingress sticky sessions: The nginx ingress controller supports cookie-based affinity:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/session-cookie-name: "ws-route"
    nginx.ingress.kubernetes.io/session-cookie-expires: "172800"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"  # Keep WS alive
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /ws
            pathType: Prefix
            backend:
              service:
                name: websocket-service
                port:
                  number: 8080

Graceful shutdown: When Kubernetes sends SIGTERM to a pod, you have terminationGracePeriodSeconds to drain connections. Signal clients to reconnect before killing the process:

process.on("SIGTERM", async () => {
  console.log("SIGTERM received, draining connections...");

  // Tell all connected clients to reconnect (they'll go to a healthy instance)
  io.emit("server-restart", { reconnectIn: 3000 });

  // Stop accepting new connections
  httpServer.close();

  // Wait for clients to reconnect elsewhere, then exit
  setTimeout(() => {
    console.log("Graceful shutdown complete");
    process.exit(0);
  }, 5000);
});

Connection Limits and OS Tuning

Each WebSocket connection uses one file descriptor. Linux defaults are restrictive:

# Check current limits
ulimit -n       # Soft limit (often 1024 or 4096)
cat /proc/sys/fs/file-max  # System-wide maximum

# Raise for production WebSocket servers
# In /etc/security/limits.conf:
* soft nofile 65535
* hard nofile 65535

# Or per-process in systemd unit:
[Service]
LimitNOFILE=65535

At 65,535 file descriptors per process and one connection per file descriptor, a single process handles ~60,000 concurrent connections (leaving headroom for OS handles). For higher concurrency, use multiple processes via Node.js cluster module — each process gets its own file descriptor table. With the Redis adapter, all cluster workers share room state transparently.

Managed Services: When to Outsource

Running your own WebSocket infrastructure at scale is meaningful engineering work. Three managed options are worth knowing:

Service Pricing Right For
Ably $29/mo base, ~$3/mo per 1M messages Apps needing reliable delivery, presence, history
Pusher $49/mo, 500 concurrent connections Smaller apps, rapid prototyping
AWS API Gateway WebSocket $1/million messages + $0.25/million connection-minutes AWS-native apps, serverless

The break-even point where self-hosting beats Ably on cost is roughly 50 million messages/month — well beyond most startups. Until then, the engineering time saved is worth more than the subscription cost.


5. Collaborative Editing Patterns

Collaborative editing is the hardest real-time problem in common web development. When two users edit the same document simultaneously, you need a conflict resolution strategy that makes the result feel seamless — no overwriting, no lost changes.

Operational Transformation: The Historical Approach

Google Docs uses Operational Transformation (OT). The core idea: every operation (insert, delete) is represented as a data structure. When two operations conflict, a transform function adjusts them so they can be applied in either order and produce the same result.

OT works but it is algorithmically complex. Getting the transformation functions right for rich text is notoriously difficult, and the server must serialize all operations through a central authority to assign ordering. It doesn't work well offline.

CRDTs: The Modern Approach

Conflict-Free Replicated Data Types (CRDTs) take a different approach: design the data structure so that concurrent operations can always be merged without conflicts, regardless of order or network partitions. No server coordination required for merging. Works offline. Converges deterministically when peers sync.

Yjs is the dominant CRDT library in 2026. It implements a high-performance CRDT for text, arrays, and maps, and has providers for every transport layer.

Yjs with y-websocket: Full Implementation

// Server: y-websocket provider
// Install: npm install y-websocket yjs ws
import { WebSocketServer } from "ws";
import { setupWSConnection } from "y-websocket/bin/utils.js";
import * as Y from "yjs";
import { LeveldbPersistence } from "y-leveldb";

// Persist document state to disk so edits survive server restarts
const persistence = new LeveldbPersistence("./doc-storage");

const wss = new WebSocketServer({ port: 1234 });

wss.on("connection", (ws, req) => {
  // Extract document name from URL path, e.g. /doc/my-project-readme
  const docName = req.url?.slice(1) ?? "default";

  // setupWSConnection handles Yjs awareness and document sync protocol
  setupWSConnection(ws, req, {
    docName,
    gc: true, // Garbage collect deleted content to prevent unbounded growth
  });
});

// Restore persisted documents on startup
wss.on("listening", async () => {
  console.log("y-websocket server running on :1234");
});
// Client: collaborative text editor with Yjs + y-websocket
// Install: npm install yjs y-websocket y-codemirror.next @codemirror/view @codemirror/state
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { yCollab } from "y-codemirror.next";
import { EditorView, basicSetup } from "codemirror";
import { EditorState } from "@codemirror/state";

// Each document has a Y.Doc — the CRDT root
const ydoc = new Y.Doc();

// The shared text type — changes here sync to all connected peers
const ytext = ydoc.getText("content");

// Connect to the y-websocket server
const provider = new WebsocketProvider(
  "ws://localhost:1234",  // y-websocket server URL
  "my-document",          // Document name (room)
  ydoc,
  {
    connect: true,
    params: { auth: getAuthToken() }, // Pass auth token in query params
  }
);

// Awareness: broadcast cursor position and user identity to all peers
provider.awareness.setLocalStateField("user", {
  name: currentUser.name,
  color: currentUser.color, // e.g. "#f97316"
  colorLight: currentUser.colorLight,
});

provider.awareness.on("change", () => {
  // Render remote cursors / presence indicators
  const states = provider.awareness.getStates();
  renderPresenceIndicators([...states.entries()]);
});

// Mount the editor — yCollab extension connects CodeMirror to Yjs
const state = EditorState.create({
  doc: ytext.toString(),
  extensions: [
    basicSetup,
    yCollab(ytext, provider.awareness), // Handles sync + cursor decorations
  ],
});

const view = new EditorView({
  state,
  parent: document.getElementById("editor")!,
});

provider.on("status", ({ status }: { status: string }) => {
  // "connected" | "disconnected"
  document.getElementById("sync-status")!.textContent = status;
});

Offline Support and Persistence

Yjs supports offline editing natively. If a user edits a document while offline, the changes are buffered in the Y.Doc. When the provider reconnects, it performs a sync operation — exchanging state vectors with the server to determine what each side is missing. All offline changes are merged without conflicts.

For server-side persistence beyond LevelDB, y-mongodb stores document state in MongoDB. The document state is stored as a binary update log, not the full text — Yjs encodes incremental updates efficiently, and the storage cost is proportional to the number of operations, not the document size.


6. Production Considerations

Getting WebSocket architecture working in development is the easy part. Making it reliable in production requires attention to several operational concerns that don't surface until traffic scales or infrastructure changes.

Monitoring

The metrics that matter for real-time infrastructure:

  • Connection count over time — steady growth is healthy; sudden drops indicate a server crash or network partition
  • Message throughput — messages/second per instance; this tells you when you're approaching per-process limits
  • Connection duration distribution — median, p95, p99; long-tail connections indicate clients that haven't received a close frame
  • Reconnection rate — high reconnection rate indicates connection instability, flaky network paths, or aggressive proxy timeouts
  • Redis pub/sub latency — the p99 of the time between publishing a message to Redis and delivering it to a connected client; should be under 10ms in a well-configured setup

With Prometheus and Grafana, instrument your WebSocket server:

import { Counter, Gauge, Histogram, register } from "prom-client";

const wsConnections = new Gauge({
  name: "ws_connections_active",
  help: "Number of active WebSocket connections",
  labelNames: ["room"],
});

const wsMessages = new Counter({
  name: "ws_messages_total",
  help: "Total WebSocket messages processed",
  labelNames: ["type", "direction"],
});

const wsMessageDuration = new Histogram({
  name: "ws_message_processing_seconds",
  help: "WebSocket message processing latency",
  buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5],
});

// Call wsConnections.inc({ room }) on connect, wsConnections.dec({ room }) on close
// Call wsMessages.inc({ type: "chat", direction: "inbound" }) on message receipt

Rate Limiting WebSocket Messages

Without rate limiting, a single misbehaving client can flood your server with messages. Implement a per-connection token bucket:

class TokenBucket {
  private tokens: number;
  private lastRefill: number;

  constructor(
    private readonly capacity: number,   // Max burst size
    private readonly refillRate: number  // Tokens added per second
  ) {
    this.tokens = capacity;
    this.lastRefill = Date.now();
  }

  consume(count = 1): boolean {
    const now = Date.now();
    const elapsed = (now - this.lastRefill) / 1000;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate);
    this.lastRefill = now;

    if (this.tokens >= count) {
      this.tokens -= count;
      return true; // Allow
    }
    return false; // Reject
  }
}

// In connection handler:
const bucket = new TokenBucket(20, 5); // 20 burst, 5 messages/sec sustained

ws.on("message", (data) => {
  if (!bucket.consume()) {
    ws.send(JSON.stringify({ error: "rate_limited", retryAfter: 1 }));
    return; // Drop the message, do not process
  }
  // ... process message
});

Authentication

Never pass authentication credentials in WebSocket message payloads — by the time you parse the first message, the connection is already established. Validate before the upgrade completes.

The correct pattern is to pass a short-lived JWT as a query parameter in the WebSocket URL:

wss://api.example.com/ws?token=eyJhbGciOiJIUzI1NiJ9...

In the server's upgrade event handler (before the WebSocket connection is established):

server.on("upgrade", (req, socket, head) => {
  const { query } = parse(req.url ?? "", true);
  const token = String(query.token ?? "");

  verifyJWT(token)
    .then((payload) => {
      // Attach user info to request for use in connection handler
      (req as any).user = payload;
      wss.handleUpgrade(req, socket, head, (ws) => {
        wss.emit("connection", ws, req);
      });
    })
    .catch(() => {
      // Reject before WebSocket connection is established
      socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
      socket.destroy();
    });
});

Query parameters are logged by proxies and visible in browser history. Use short-lived tokens (60-second TTL) generated specifically for this connection. Never reuse long-lived API keys in WebSocket URLs.

Binary Protocols

For high-throughput message streams — sensor data, game state, financial ticks — JSON is wasteful. A JSON-encoded object {"type":"tick","symbol":"AAPL","price":213.40} is ~45 bytes. The MessagePack equivalent is ~22 bytes. At 100,000 messages/second across 10,000 connections, that difference is 230GB/day in saved bandwidth.

import { encode, decode } from "@msgpack/msgpack";

// Sender
ws.send(encode({ type: "tick", symbol: "AAPL", price: 213.40 }));

// Receiver
ws.on("message", (data: Buffer) => {
  const message = decode(data) as Record<string, unknown>;
  // ... process message
});

Graceful Shutdown

When deploying a new version, your load balancer will route new connections to the updated instances. Existing connections on old instances need to be drained gracefully — not killed abruptly, which would cause a poor user experience and a sudden reconnect spike:

let isShuttingDown = false;

process.on("SIGTERM", () => {
  isShuttingDown = true;
  console.log("Shutting down — draining connections");

  // Stop accepting new connections
  wss.close();

  // Notify all connected clients to reconnect elsewhere
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      // Send application-level signal, then close cleanly
      client.send(JSON.stringify({ type: "server-restart", reconnectIn: 2000 }));
      setTimeout(() => client.close(1001, "Server going away"), 2000);
    }
  });

  // Force exit after 30 seconds (safety net)
  setTimeout(() => process.exit(0), 30_000);
});

// Reject new connections during shutdown
wss.on("connection", (ws) => {
  if (isShuttingDown) {
    ws.close(1013, "Server shutting down, please reconnect");
    return;
  }
  // ... normal connection handling
});

Conclusion

Three transports, three different design points:

WebSockets when you need full-duplex communication — both the client and server initiate messages independently. Chat, multiplayer, collaborative editing, live gaming. Accept the operational complexity: stateful connections, sticky sessions or Redis pub/sub, OS-level file descriptor tuning.

SSE when the server pushes and the client listens. Notification feeds, live dashboards, LLM token streaming, activity streams. It is simpler to operate than WebSockets, runs over plain HTTP with no special load balancer configuration, and the EventSource built-in handles reconnection and event replay for you.

WebRTC when you need peer-to-peer media or require the absolute minimum latency for binary data. Video calls, screen sharing, real-time audio, P2P games. The signaling infrastructure is still your problem (typically WebSockets), but the data path bypasses your servers entirely.

For scaling, the decision is binary below 10,000 concurrent connections per instance: a single well-tuned Node.js process is sufficient. Above that threshold, Redis pub/sub with Socket.io's adapter is the standard horizontal scaling pattern. Managed services like Ably are worth their cost until you hit ~50 million messages/month.

For collaborative editing specifically, Yjs is the right library for 2026. Its CRDT approach handles offline edits, conflict resolution, and presence awareness without requiring you to implement any of that logic yourself.

The default mistake is reaching for WebSockets everywhere. Most features that feel like they need WebSockets actually only need one-way server push — and SSE gets there with less infrastructure, less client code, and better behavior on reconnect. Pick the simplest transport that satisfies your actual requirements.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-12 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Microservices vs Monolith in 2026: The Honest Decision Framework

Hero image

Introduction

In 2016, the industry consensus was loud and confident: monoliths are legacy, microservices are the future. Every conference talk, every architectural review, every greenfield project brief had the same answer. Split everything into services. Deploy them independently. Scale them independently. The architecture would mirror the organization, and the organization would ship faster.

A decade later, the honest post-mortems are piling up. Amazon famously decomposed their retail monolith into services, and that decomposition genuinely enabled their growth. But Amazon also has thousands of engineers, a dedicated distributed systems platform team, and the scale to justify the overhead. Most teams that cargo-culted the pattern got the complexity without the scale that justifies it. Monoliths were rebuilt from scratch as distributed systems and became harder to understand, harder to debug, and slower to ship.

Shopify runs one of the world's largest e-commerce platforms on a Ruby on Rails monolith. Stack Overflow serves millions of developers per month on nine physical servers. Prime Video's video monitoring team made headlines in 2023 when they collapsed their microservices architecture back into a monolith and reduced costs by 90 percent. These aren't edge cases or embarrassing admissions — they're engineering teams making correct decisions for their scale and team structure.

The microservices vs. monolith debate was always the wrong framing. The right question is: what level of distribution is right for this team, at this scale, with these constraints? That question has a different answer in 2026 than it did in 2016, because the costs of getting it wrong are better understood. We have more failure data. We have more honesty about the operational tax that distributed systems impose. And we have a clearer-eyed picture of when distribution actually delivers its promised benefits versus when it just moves the complexity from code into the network.

This post is an honest decision framework — not a technology endorsement. We will look at when monoliths are the right call, when microservices are genuinely justified, how to decompose when the time comes, how to handle service communication without building a reliability nightmare, and what the real operational costs look like before you commit to them. Position taken upfront: for most teams at most stages, a well-structured modular monolith is the correct default. Extract services when you have a specific, demonstrable reason. Not because a blog post said to.


1. The Monolith Is Not the Problem

The word "monolith" has become a pejorative in engineering culture, synonymous with technical debt, deployment risk, and legacy thinking. This is a category error. A monolith is a deployment topology, not a quality judgment. The distinction that actually matters is not monolith vs. microservices — it is modular vs. tangled.

A tangled monolith is what people are actually afraid of. It is a codebase where the user service imports the billing service which imports the analytics service which imports the user service again. Every change ripples unpredictably across the system. The test suite takes 45 minutes because nothing can be tested in isolation. Deployment is a full-system rebuild, and every release is a roulette wheel because nobody knows what touched what. This is a real problem, but it is a problem of internal architecture, not deployment topology. Converting a tangled monolith into microservices does not fix the tangle — it promotes it to a distributed tangle, which is harder to observe and harder to fix.

A modular monolith is structured around clear domain boundaries with explicit interfaces between modules. The payment module exposes a PaymentService interface. The order module calls that interface. Neither module reaches into the other's internals. The modules are independently testable, internally cohesive, and externally loosely coupled. The fact that they all run in the same process is incidental. Netflix's original monolith was modular. So is the Django codebase powering Instagram, and the Rails codebase powering Shopify.

Shopify is the canonical example worth sitting with. At the time of writing, Shopify processes more than $10 billion in GMV annually, handles traffic spikes that would buckle most architectures, and runs a global merchant and consumer platform — all on a Rails monolith they call their "modular monolith." They have invested heavily in defining module boundaries, preventing cross-module data access, and building internal tooling to enforce the rules. It is not simple, but it is significantly simpler than the alternative. Their chief architect has said publicly that the modular monolith is the right choice for Shopify at Shopify's scale, and that rewriting it as microservices would consume years of engineering effort for uncertain benefit.

Stack Overflow is the other number to keep in your head. Nine physical servers. Millions of page views per month. The team is small, the deployment is simple, and the performance is exceptional — because SQL Server, careful indexing, and in-process caching inside a single deployment unit beats the overhead of service-to-service network calls at that traffic volume.

graph TB subgraph "Tangled Monolith — The Real Problem" US1[User Service] -->|direct DB access| PD1[(Payment DB)] BS1[Billing Service] -->|circular import| US1 AS1[Analytics Service] -->|shared global state| BS1 OS1[Order Service] -->|direct table join| US1 US1 -->|side-effect import| AS1 end subgraph "Modular Monolith — Same Process, Clean Boundaries" US2[User Module] -->|interface| PS2[PaymentService Interface] BS2[Billing Module] -->|implements| PS2 AS2[Analytics Module] -->|event subscriber| EB2[Internal Event Bus] OS2[Order Module] -->|emits events| EB2 US2 -->|own DB schema| UD2[(users schema)] BS2 -->|own DB schema| BD2[(billing schema)] OS2 -->|own DB schema| OD2[(orders schema)] end

The real signals that a monolith has a structural problem — and not that it needs to be decomposed into services — are: circular dependencies between modules, a shared database god object where every module reads every table, the inability to run any subset of the codebase in isolation, and deployment gates that require every team to sign off because every change can affect every other change. These are problems you fix through refactoring and internal boundary enforcement, not through network boundaries. Building clear module interfaces is the prerequisite to decomposition. If you cannot define a clean interface between two modules inside a monolith, extracting them as services will not create one — it will just add latency to the confusion.

The design principle that matters most for future decomposability is domain-first module organization. Organize code by business domain (orders, payments, inventory, notifications), not by technical layer (controllers, services, repositories). Vertical slices that own their domain from API to database are far easier to extract into independent services later than horizontal layers that cut across every domain. Build the modular monolith correctly, and you have an extraction-ready architecture. Skip the modular structure in favor of early extraction, and you will be debugging distributed transactions before your user base justifies it.

Architecture diagram

2. When Microservices Are Justified

The useful question is not "should we use microservices?" but "do we have a specific problem that service extraction solves, where the solution's cost is less than the problem's cost?" Most of the time the answer is no. Some of the time — at sufficient scale, with sufficient team complexity — the answer is yes.

Independent scaling requirements are the clearest technical justification. If your payment processing workload requires 10x the compute during peak hours and your user authentication workload requires none, deploying them as separate services means you scale payment horizontally without paying for unused authentication capacity. Inside a monolith, you scale everything together. At the scale where that inefficiency costs meaningful money — typically when your infrastructure bill is in the tens of thousands per month — this math starts to matter. At startup scale, the waste from over-provisioning a single deployment is negligible compared to the engineering overhead of managing multiple services.

Different deployment cadences are the second strong technical justification. If your ML inference service needs to redeploy every hour as the model is retrained, and your core user service deploys once a month, coupling those two inside a monolith means every model refresh triggers a full system deployment, with all the associated risk, testing, and coordination. Decoupling their deployment cycles through service boundaries is a direct reduction in deployment risk, not an increase.

Team autonomy at Conway's Law scale is the organizational justification. Conway's Law states that systems reflect the communication structures of the organizations that build them. The inverse is also useful: if you have three independent teams with distinct ownership boundaries, a monolith will create constant merge conflicts, deployment coordination costs, and organizational friction that a services-based architecture resolves. This is not a technical requirement — it is an organizational one. But it is real. The signal to watch for is: are multiple teams fighting over deployment? Are you scheduling release windows to coordinate between teams? Are merge conflicts in shared modules a weekly source of delay? That is the organizational pressure that service extraction is designed to relieve.

Compliance isolation is the fourth justification, and often underweighted. PCI DSS scope is a real concern for any team handling payment card data. If you can isolate all cardholder data handling into a single service with its own infrastructure, you reduce the audit surface area from your entire system to one bounded component. The same logic applies to HIPAA compliance for health data, SOC 2 boundaries, and GDPR data residency requirements. Service extraction for compliance isolation is justified at any scale because the alternative — scoping your entire monolith under PCI DSS — is significantly more expensive in audit costs and ongoing compliance overhead.

flowchart TD Start([New service extraction request]) --> Q1{Do 2+ teams fight
over deploys monthly?} Q1 -->|No| Q2{Wildly different
scaling needs?} Q1 -->|Yes| Q3{Team size > 15?} Q3 -->|No| Stay[Keep in monolith\nFix process, not architecture] Q3 -->|Yes| Extract[Extract service] Q2 -->|No| Q4{Different deploy
cadences causing risk?} Q2 -->|Yes| Q5{Cost waste > $5k/mo?} Q5 -->|No| Stay Q5 -->|Yes| Extract Q4 -->|No| Q6{Compliance isolation
required? PCI/HIPAA} Q4 -->|Yes| Extract Q6 -->|Yes| Extract Q6 -->|No| Stay style Extract fill:#2d6a4f,color:#fff style Stay fill:#6b2737,color:#fff

The signal that is often mistaken for a microservices justification is team or engineer count. "We have 50 engineers, therefore we need microservices" is not valid logic. You need microservices when 50 engineers are organized into independent product teams with independent ownership, independent deployment, and independent scaling requirements. If 50 engineers are all working on the same product with shared ownership and coordinated releases, a modular monolith serves them better than services. The 2-pizza team rule from Amazon applies to the team ownership model, not to headcount alone.

The signal that actually indicates readiness for service extraction is operational maturity: do you have distributed tracing deployed? Do you have a service registry and health check infrastructure? Do you have on-call rotations capable of debugging cross-service failures at 2am? Without those foundations, extracting a service creates problems you cannot diagnose. Build the observability platform before you need it to debug a production incident in a distributed system.


3. Decomposition Patterns

When the decision to extract a service is made — based on the framework above, not on hype — the implementation matters enormously. Big-bang rewrites are the highest-risk migration path and the most common mistake. Every successful decomposition from a production system uses incremental migration patterns.

The Strangler Fig is the most battle-tested incremental migration pattern. The name comes from the strangler fig tree, which grows around an existing tree and gradually replaces it. In software, you route a subset of traffic to the new service while the old code still handles the rest. Over months, you increase the new service's traffic share, fix its bugs under production load, and eventually decommission the old code path. The monolith shrinks. The new service grows. At no point do you have a hard cutover.

# strangler_fig_router.py
# Routes requests to either the legacy monolith handler or the new payment service
# based on a feature flag. Enables gradual traffic migration with instant rollback.

import os
import httpx
from typing import Optional
from dataclasses import dataclass

# Feature flag thresholds — increase these gradually as confidence builds
# 0.0 = all traffic to legacy, 1.0 = all traffic to new service
PAYMENT_SERVICE_TRAFFIC_PERCENT = float(os.getenv("PAYMENT_SERVICE_TRAFFIC_PCT", "0.0"))

@dataclass
class PaymentRequest:
    order_id: str
    amount_cents: int
    currency: str
    customer_id: str

@dataclass
class PaymentResult:
    success: bool
    transaction_id: Optional[str]
    error: Optional[str]

class StranglerFigPaymentRouter:
    """
    Routes payment processing requests between the legacy monolith handler
    and the new standalone payment service. Uses deterministic hashing on
    order_id so the same order always goes to the same backend during
    migration — prevents split-brain issues where one system charges but
    the other records the transaction.
    """

    def __init__(self, legacy_handler, new_service_url: str):
        self.legacy_handler = legacy_handler
        self.new_service_url = new_service_url
        self.http_client = httpx.AsyncClient(timeout=5.0)

    def _should_use_new_service(self, order_id: str) -> bool:
        """
        Deterministic routing: hash the order_id to decide which backend
        handles this request. Same order_id always routes consistently,
        regardless of when the request arrives or which server handles it.
        """
        # Simple consistent hash: use last 4 hex chars of order_id
        # to get a stable 0-9999 bucket, then compare to threshold
        bucket = int(order_id[-4:], 16) % 10000
        threshold = int(PAYMENT_SERVICE_TRAFFIC_PERCENT * 100)
        return bucket < threshold

    async def process_payment(self, request: PaymentRequest) -> PaymentResult:
        if self._should_use_new_service(request.order_id):
            return await self._call_new_service(request)
        else:
            return await self._call_legacy(request)

    async def _call_new_service(self, request: PaymentRequest) -> PaymentResult:
        """Call the extracted payment microservice via HTTP."""
        try:
            response = await self.http_client.post(
                f"{self.new_service_url}/v1/payments",
                json={
                    "order_id": request.order_id,
                    "amount_cents": request.amount_cents,
                    "currency": request.currency,
                    "customer_id": request.customer_id,
                },
            )
            data = response.json()
            if response.status_code == 200:
                return PaymentResult(
                    success=True,
                    transaction_id=data["transaction_id"],
                    error=None,
                )
            return PaymentResult(success=False, transaction_id=None, error=data.get("error"))
        except httpx.TimeoutException:
            # On timeout, fall back to legacy — safety net during migration
            return await self._call_legacy(request)

    async def _call_legacy(self, request: PaymentRequest) -> PaymentResult:
        """Call the original monolith payment handler."""
        return await self.legacy_handler.process_payment(request)

Branch by Abstraction works when you cannot control routing at the HTTP layer. Introduce an interface that both the old and new implementations satisfy. Initially the interface delegates to the old code. You write the new implementation behind the interface. Once the new implementation passes tests, you flip the implementation at the injection point. The calling code never changes.

Domain-Driven Design bounded contexts should define your service boundaries, not technical convenience. A bounded context is a subsystem with its own domain model, its own language, and its own data. The Order concept in your ordering context has different attributes and behaviors than the Order concept in your fulfillment context. Trying to share one Order model across both creates the tight coupling that makes services hard to evolve independently.

Database-per-service is the hardest constraint and the most important one. A shared database between two services is not a microservices architecture — it is a distributed monolith with all the overhead of services and none of the independence. If service A and service B both read from the same table, they cannot be deployed or scaled independently. Any schema change requires coordinating both services. The independence that justifies the complexity of separate services requires separate data ownership. This means denormalization. It means eventual consistency between services. It means accepting that you cannot use a JOIN across service boundaries. Those costs are real, and they are why shared databases are so tempting. They are also why so many microservices migrations fail to deliver their promised independence.

sequenceDiagram participant Client participant Router as Strangler Fig Router participant Legacy as Monolith (Legacy) participant New as Payment Service (New) Note over Router: Phase 1: 0% to new service Client->>Router: POST /payments Router->>Legacy: forward 100% of traffic Legacy-->>Client: response Note over Router: Phase 2: 10% to new service Client->>Router: POST /payments Router->>Router: hash(order_id) % 10000 < 1000? Router->>New: 10% of traffic (canary) New-->>Client: response Note over Router: Phase 3: 100% to new service Client->>Router: POST /payments Router->>New: forward all traffic New-->>Client: response Note over Legacy: Decommission legacy path

The anti-corruption layer pattern prevents new service boundaries from being contaminated by the legacy domain model. When extracting a service from a monolith, the legacy codebase has its own internal model — often a god object with 60 fields that represents "everything about a customer." The new service has a clean, bounded model. The anti-corruption layer is a translation component at the boundary that converts the legacy model into the new service's model and back. Without it, the new service's design gets polluted by the legacy model's shape, and you have not actually established a new boundary — you have just moved the legacy model into a new process.

Comparison visual

4. Service Communication Patterns

How services talk to each other is where distributed systems earn their complexity tax. Every communication pattern is a tradeoff between latency, reliability guarantees, operational overhead, and coupling. Getting this wrong is the most common cause of microservices failures in production.

Synchronous communication via REST or gRPC is appropriate when you need a response before proceeding. A payment authorization must succeed before you confirm an order. A user lookup must return before you render a page. REST is universal and easy to debug. gRPC is faster (binary Protocol Buffers over HTTP/2) and enforces schema via .proto files. Use gRPC for internal service-to-service calls where you control both ends. Use REST for external-facing APIs where clients are diverse.

The fundamental problem with synchronous communication in a distributed system is temporal coupling. If service A calls service B synchronously, and service B is slow or unavailable, service A is slow or unavailable. Synchronous call chains compound: 100ms at each of five service hops means 500ms minimum latency for the calling service, plus the probability of failure at each hop multiplied together. If each service has 99.9% availability, five synchronous dependencies gives you 99.5% availability for the composite operation — before accounting for network failures.

Asynchronous communication via message queues (Kafka, RabbitMQ, Redis Streams) decouples services temporally. When an order is placed, the order service publishes an OrderPlaced event and returns immediately. The inventory service, notification service, and analytics service each consume that event in their own time. The order service does not know or care whether any of them are available when it publishes. This eliminates temporal coupling at the cost of eventual consistency — the inventory service will subtract stock, but not necessarily before the next request arrives.

# saga_choreography.py
# Implements the Saga pattern via event choreography for distributed transactions.
# Each service listens for events, performs its local transaction, and emits
# the next event in the saga chain. On failure, each service emits a compensating event.

import json
import asyncio
from enum import Enum
from dataclasses import dataclass, asdict
from typing import Optional

class SagaEventType(str, Enum):
    # Forward events — happy path
    ORDER_PLACED = "order.placed"
    PAYMENT_RESERVED = "payment.reserved"
    INVENTORY_RESERVED = "inventory.reserved"
    ORDER_CONFIRMED = "order.confirmed"

    # Compensating events — rollback path
    PAYMENT_FAILED = "payment.failed"
    INVENTORY_FAILED = "inventory.failed"
    PAYMENT_RELEASED = "payment.released"  # compensate payment.reserved
    ORDER_CANCELLED = "order.cancelled"

@dataclass
class SagaEvent:
    event_type: SagaEventType
    order_id: str
    correlation_id: str         # tracks the full saga across services
    payload: dict
    failure_reason: Optional[str] = None

class PaymentService:
    """
    Handles payment.reserved and payment.failed events.
    On order.placed: attempt to reserve funds. Emit payment.reserved or payment.failed.
    On inventory.failed: emit payment.released to compensate the reservation.
    """

    async def handle_event(self, event: SagaEvent, emit):
        if event.event_type == SagaEventType.ORDER_PLACED:
            await self._reserve_payment(event, emit)
        elif event.event_type == SagaEventType.INVENTORY_FAILED:
            await self._release_payment(event, emit)

    async def _reserve_payment(self, event: SagaEvent, emit):
        order = event.payload
        try:
            # Idempotency key: use correlation_id so retries are safe
            transaction_id = await self._charge_card(
                customer_id=order["customer_id"],
                amount_cents=order["amount_cents"],
                idempotency_key=event.correlation_id,
            )
            await emit(SagaEvent(
                event_type=SagaEventType.PAYMENT_RESERVED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload={"transaction_id": transaction_id, **order},
            ))
        except PaymentDeclinedError as e:
            await emit(SagaEvent(
                event_type=SagaEventType.PAYMENT_FAILED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
                failure_reason=str(e),
            ))

    async def _release_payment(self, event: SagaEvent, emit):
        # Compensating transaction: reverse the reservation
        await self._refund_charge(
            transaction_id=event.payload["transaction_id"],
            idempotency_key=f"refund-{event.correlation_id}",
        )
        await emit(SagaEvent(
            event_type=SagaEventType.PAYMENT_RELEASED,
            order_id=event.order_id,
            correlation_id=event.correlation_id,
            payload=event.payload,
        ))

    async def _charge_card(self, customer_id, amount_cents, idempotency_key):
        # Stubbed: actual Stripe/Adyen call here
        return f"txn_{idempotency_key[:8]}"

    async def _refund_charge(self, transaction_id, idempotency_key):
        # Stubbed: actual refund call here
        pass

class InventoryService:
    """
    Listens for payment.reserved. Attempts to reserve stock.
    Emits inventory.reserved or inventory.failed.
    On failure, the payment service will see inventory.failed and release the charge.
    """

    async def handle_event(self, event: SagaEvent, emit):
        if event.event_type == SagaEventType.PAYMENT_RESERVED:
            await self._reserve_stock(event, emit)

    async def _reserve_stock(self, event: SagaEvent, emit):
        order = event.payload
        try:
            await self._decrement_inventory(
                sku=order["sku"],
                quantity=order["quantity"],
                idempotency_key=event.correlation_id,
            )
            await emit(SagaEvent(
                event_type=SagaEventType.INVENTORY_RESERVED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
            ))
        except InsufficientStockError as e:
            await emit(SagaEvent(
                event_type=SagaEventType.INVENTORY_FAILED,
                order_id=event.order_id,
                correlation_id=event.correlation_id,
                payload=order,
                failure_reason=str(e),
            ))

    async def _decrement_inventory(self, sku, quantity, idempotency_key):
        pass  # Actual inventory update here

class PaymentDeclinedError(Exception): pass
class InsufficientStockError(Exception): pass

The Circuit Breaker prevents cascading failures. When service B is failing, service A should stop trying to call it immediately rather than queuing up requests that will timeout after five seconds each, exhausting connection pools, and propagating the failure upstream. A circuit breaker wraps a remote call and tracks failure rate. When failures exceed a threshold, the circuit "opens" and requests fail fast (immediately, without attempting the call). After a cooldown window, the circuit moves to "half-open" and tries a test request. If it succeeds, the circuit closes and normal traffic resumes.

# circuit_breaker.py
# A minimal circuit breaker with exponential backoff for remote service calls.
# States: CLOSED (normal), OPEN (failing fast), HALF_OPEN (testing recovery).

import time
import asyncio
from enum import Enum
from typing import Callable, TypeVar, Awaitable

T = TypeVar("T")

class CircuitState(Enum):
    CLOSED = "closed"       # Normal operation
    OPEN = "open"           # Failing fast — not attempting calls
    HALF_OPEN = "half_open" # Testing if service has recovered

class CircuitBreakerOpen(Exception):
    """Raised when a call is blocked because the circuit is open."""
    pass

class CircuitBreaker:
    """
    Circuit breaker with exponential backoff on retry windows.
    failure_threshold: number of failures before circuit opens
    recovery_timeout: seconds to wait before attempting recovery (HALF_OPEN)
    success_threshold: consecutive successes in HALF_OPEN before closing
    """

    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: float = 30.0,
        success_threshold: int = 2,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.success_threshold = success_threshold

        self._state = CircuitState.CLOSED
        self._failure_count = 0
        self._success_count = 0
        self._last_failure_time: float = 0.0
        self._backoff_multiplier = 1.0   # Increases with each OPEN cycle

    @property
    def state(self) -> CircuitState:
        if self._state == CircuitState.OPEN:
            # Check if recovery window has elapsed
            elapsed = time.monotonic() - self._last_failure_time
            recovery_window = self.recovery_timeout * self._backoff_multiplier
            if elapsed >= recovery_window:
                self._state = CircuitState.HALF_OPEN
                self._success_count = 0
        return self._state

    async def call(self, func: Callable[..., Awaitable[T]], *args, **kwargs) -> T:
        """Execute func through the circuit breaker."""
        current_state = self.state

        if current_state == CircuitState.OPEN:
            raise CircuitBreakerOpen(
                f"Circuit is OPEN. Next retry in "
                f"{self.recovery_timeout * self._backoff_multiplier:.0f}s"
            )

        try:
            result = await func(*args, **kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _on_success(self):
        if self._state == CircuitState.HALF_OPEN:
            self._success_count += 1
            if self._success_count >= self.success_threshold:
                # Service recovered — close the circuit and reset backoff
                self._state = CircuitState.CLOSED
                self._failure_count = 0
                self._backoff_multiplier = 1.0
        elif self._state == CircuitState.CLOSED:
            # Reset failure count on any success (sliding window behavior)
            self._failure_count = max(0, self._failure_count - 1)

    def _on_failure(self):
        self._failure_count += 1
        self._last_failure_time = time.monotonic()

        if self._failure_count >= self.failure_threshold:
            if self._state != CircuitState.OPEN:
                # First time opening: start backoff at 1x
                self._state = CircuitState.OPEN
            else:
                # Already open — exponential backoff up to 8x the base timeout
                self._backoff_multiplier = min(self._backoff_multiplier * 2, 8.0)

# Usage example
payment_circuit = CircuitBreaker(failure_threshold=5, recovery_timeout=30.0)

async def get_payment_status(order_id: str) -> dict:
    try:
        return await payment_circuit.call(
            payment_service_client.get_status,
            order_id=order_id,
        )
    except CircuitBreakerOpen:
        # Return cached status or degraded response rather than failing hard
        return {"status": "unknown", "degraded": True}

A service mesh (Istio, Linkerd) handles cross-cutting concerns — mutual TLS between services, circuit breaking, retry logic, distributed tracing, traffic splitting — at the infrastructure layer without code changes. For teams with 10+ services, the investment in a service mesh pays off by removing dozens of per-service implementations of the same retry/timeout/mTLS logic. For teams with 3-5 services, the operational overhead of the mesh itself (Istio in particular is operationally demanding) likely exceeds the benefit.


5. The Distributed Systems Tax

Every microservices adoption prospectus focuses on the benefits. The tax is real and should be stated plainly before any decomposition decision is made.

Network failures are now a first-class concern. In a monolith, a function call either returns or throws an exception from the called code. In a distributed system, the call can fail because the network dropped the packet, because the remote service is restarting, because DNS resolution failed, because the TLS handshake timed out, because a load balancer returned a 502, or because the remote service returned a 200 but the response was truncated. Every cross-service call requires timeout handling, retry logic with exponential backoff and jitter, and circuit breakers. This is not optional. A service that does not handle these failure modes will eventually fail in production in a way that cascades across your entire system.

Distributed tracing is a prerequisite, not an afterthought. When a user request in a monolith fails, you have one stack trace in one log. When a request traverses five services and fails, you have five partial logs in five log streams with no correlation between them — unless you have implemented distributed tracing. OpenTelemetry with Jaeger or Honeycomb, propagating trace IDs through every service call, is the minimum viable observability for a microservices architecture. Without it, debugging a production incident requires correlating timestamps across five dashboards and reconstructing the call graph manually. This is what "flying blind" looks like in practice, and it happens at 2am.

Data consistency requires explicit engineering. The ACID transaction guarantee that a relational database gives you inside a monolith does not extend across service boundaries. When an order is placed and requires a payment reservation and an inventory reservation, you cannot wrap those three operations in a single database transaction. You must implement the Saga pattern, with compensating transactions for each step that can fail. You must design every operation to be idempotent so that retries do not double-charge customers. You must accept that the system will be in inconsistent intermediate states during normal operation and design the user experience around eventual consistency.

Operational complexity scales linearly with service count. Each new service requires: its own deployment pipeline, its own container registry entry, its own Kubernetes namespace and resource limits, its own alert policies, its own runbook, its own on-call escalation path, its own log aggregation configuration, and its own metrics dashboard. A team that manages ten services needs ten times the operational infrastructure of a team with one monolith. This overhead does not scale down when services are small — a two-function service costs nearly as much to operate as a large one.

The latency math is unforgiving. A synchronous call chain of five services, each adding 20ms of internal processing time and 10ms of network latency on a low-latency internal network, contributes 150ms of minimum latency to the terminal response. The same logic executed as five function calls inside a monolith takes microseconds. This only matters when latency is a user-facing concern — interactive UIs, APIs with SLAs, real-time pipelines — but it matters a lot in those contexts. The "just throw Varnish in front of it" solution does not work when the response contains personalized or real-time data.

The Prime Video story is worth the specifics. Their video quality monitoring system was originally built as microservices on AWS Lambda and Step Functions. The system worked, but at scale the inter-service communication costs and Lambda invocation costs grew with data volume. When they collapsed it into a monolith running on a single ECS service, costs dropped by 90% and scalability improved because the bottleneck had been the orchestration layer, not the processing logic. The key insight: their data pipeline had high throughput and low latency requirements between steps — exactly the workload profile where in-process function calls vastly outperform inter-service network calls. The microservices architecture had been chosen by default, not by analysis.


6. The Majestic Monolith and Modular Approaches

The 2026 landscape has produced a clearer vocabulary for the middle ground. The "Majestic Monolith" — a term popularized by DHH and the Rails community — describes a well-structured single-deployment application that deliberately eschews distribution until the evidence demands it. The "Modular Monolith" is its more formal cousin: a monolith organized around hard domain module boundaries enforced by tooling, not just convention.

For most teams at most stages, the modular monolith is the right default. This is not a consolation prize. It is the correct engineering decision given the available evidence. A modular monolith built with clean domain boundaries, interface-based module communication, and vertical slicing by feature is a genuinely production-grade architecture. It deploys as a single unit, which means one pipeline, one deployment, one set of dashboards. It fails as a single unit, which means one stack trace, one log stream, one place to look. And it can be decomposed incrementally when and if the evidence of scaling or team pressure appears.

The escalation path for a modular monolith is well-defined. You start with vertical feature slices: the orders module owns everything from the API endpoint to the database table, with no cross-module data access. Interfaces define the contract between modules. An internal event bus handles cross-cutting concerns like notifications and analytics without creating import cycles. When a specific module shows the characteristics that justify extraction — independent scaling needs, different deployment cadence, compliance isolation, team ownership friction — you apply the Strangler Fig and extract exactly that module. The rest of the system continues to run as before. The modular structure you built from day one means the extracted module already has a clean interface — you are adding a network boundary, not redesigning the module.

Mini-services occupy a useful middle ground that does not appear in most architectural discussions: one-concern-per-process without full microservices overhead. A worker process that handles asynchronous email sending is a mini-service. A cron process that runs nightly batch reconciliation is a mini-service. They deploy separately, can be scaled independently, and have narrow enough scope that they do not require a full distributed systems framework. They share the main application database under a single schema owner. This pattern gives you the deployment independence that matters (email sending can go down without affecting the main API) without the data consistency complexity of full service decomposition.

Internal service boundaries enforced by linting tools — the dependency-cruiser for JavaScript, import-linter for Python, custom Go module constraints — are the unglamorous work that makes the modular monolith actually work. Without enforcement, module boundaries drift. Engineers add a convenience import across a boundary. Then another. Within six months the modular structure exists in the documentation but not in the codebase. The architectural tests that enforce "orders module must not import from payments module" are the difference between a real modular monolith and a tangled one with aspirational documentation.


Conclusion

The honest decision framework is this: start with a modular monolith, organized around domain boundaries, with interfaces between modules and vertical feature slices. This is the correct default for new projects and for teams under 15-20 engineers working on a single product. Build it well — enforce the module boundaries with tooling, maintain clear interfaces, resist the urge to share database tables across module lines. You will have an architecture that is easy to understand, easy to debug, cheap to operate, and ready to decompose when the time comes.

Extract a service when you have a specific, demonstrable, quantified reason: a compliance boundary that scopes the audit surface, a scaling requirement that is costing real money, a deployment cadence mismatch that is causing real risk, or a team ownership conflict that is causing real friction. Not because your architecture looks like what Netflix presented at QCon. Not because your team has hit 30 engineers. Not because the new engineer from Google says that's how they do it there.

The companies that have gotten this right — Shopify, Stack Overflow, Basecamp, and even the Prime Video team when they made their reversal — have one thing in common: they made architectural decisions based on the specific problems in front of them, not on the architectural fashion of the moment. Microservices are not a destination. They are a tool. The tool has a real cost. Use it when the problem justifies the cost, and not before.

The worst outcome is a distributed monolith: all the operational complexity of microservices with all the tight coupling of a tangled monolith. It is achievable by extracting services without establishing clean domain boundaries, by sharing a database across services, or by building synchronous call chains without fault tolerance. Avoid it by doing the hard work of module design first, inside the monolith, before any extraction happens. Get the boundaries right in code before you promote them to network boundaries.

Start structured. Extract deliberately. Measure first.


Sources

About the Author

Toc Am

Founder of AmtocSoft. Writing practical deep-dives on AI engineering, cloud architecture, and developer tooling. Previously built backend systems at scale. Reviews every post published under this byline.

LinkedIn X / Twitter

Published: 2026-06-11 · Updated: 2026-04-18 · Written with AI assistance, reviewed by Toc Am.

Get These In Your Inbox

Weekly deep-dives on AI engineering, no fluff. Join the newsletter →

Subscribe (free)

Or grab the book ($39, ~100 pages) · Buy me a coffee

Buy Me a Coffee · 🔔 YouTube · 💼 LinkedIn · 🐦 X/Twitter

Attention Is All You Need, Explained Simply

We published a plain-language walkthrough of the 2017 transformer paper — queries, keys, values, multi-head attention, and why no-recurrence...