Skip to main content
Back to Research
Field ReportDeep Readinfrastructure-intelligence

Real-Time Web Architecture in 2026 — Patterns That Scale

WebSocket vs. SSE vs. polling: the actual tradeoffs, connection management realities, backpressure, client reconnection, and optimistic UI patterns for…

Abstract

Real-time web architecture requires choosing the right transport for the access pattern. This article covers the concrete tradeoffs between WebSockets, Server-Sent Events, and polling; connection lifecycle management under load; backpressure strategies; and optimistic UI with conflict resolution for systems where network latency is a user experience problem.

April 1, 2026
9 min read

Choosing the Transport

The first decision in real-time architecture is not which library to use — it's which transport primitive fits the access pattern. Getting this wrong costs you in operational complexity, infrastructure cost, and client-side bugs that are hard to reproduce.

WebSockets provide a full-duplex persistent connection. The client can send messages to the server at any time; the server can push messages to the client at any time. The protocol overhead is minimal after the handshake. WebSockets are the right choice when:

  • The client sends messages to the server at high frequency (collaborative editing, multiplayer gaming, chat)
  • The server needs to push updates that include per-connection state
  • You need sub-100ms round trips and polling latency is unacceptable

Server-Sent Events (SSE) are a one-directional HTTP stream from server to client. The connection is persistent but the client sends data through separate HTTP requests. SSE is the right choice when:

  • The data flow is predominantly server-to-client (live feeds, dashboards, notifications)
  • You want HTTP/2 multiplexing and automatic reconnection behavior for free
  • Your infrastructure handles HTTP well but WebSocket connections require special configuration
  • You need to work through proxies and load balancers without custom tuning

Long polling is an HTTP request that the server holds open until it has data to return. The client immediately re-requests after receiving a response. This is the correct choice when:

  • Events are infrequent (one per minute or less)
  • Your infrastructure cannot handle persistent connections efficiently (serverless, some CDN configurations)
  • You need the simplest possible implementation with minimal infrastructure requirements

Regular polling (request every N seconds regardless of data availability) is almost never the right answer in 2026. It wastes server resources and bandwidth, and introduces latency proportional to the poll interval. Long polling is strictly better for most use cases.

Connection Management Under Load

A WebSocket server maintaining 100,000 concurrent connections is a different architecture problem than one maintaining 100. The fundamental constraint is file descriptors — each connection is a file descriptor, and the OS limit (typically 65,536 per process without tuning) bounds your connection count.

Horizontal scaling is the answer, but WebSocket scaling has a wrinkle: messages from a specific server can only reach clients connected to that server. If user A is on server 1 and sends a message that user B on server 2 should receive, the message must transit a shared pub/sub layer:

// Server-side: publish to Redis when a message arrives
websocket.on('message', async (raw) => {
  const message = parseMessage(raw);
  await redis.publish(`channel:${message.roomId}`, JSON.stringify(message));
});

// Server-side: subscribe to Redis and forward to connected clients
const subscriber = redis.duplicate();
await subscriber.subscribe('channel:*', (message, channel) => {
  const roomId = channel.split(':')[1];
  for (const client of getClientsInRoom(roomId)) {
    client.send(message);
  }
});

Redis Pub/Sub is the standard solution for this. Each application server subscribes to all channels relevant to its connected clients. When a message arrives on Redis, it's forwarded to the appropriate connected clients on that server. This adds ~1ms latency for the Redis hop but enables horizontal scaling without sticky sessions.

Connection pooling matters on the client side too. Multiple tabs of the same application should share a single WebSocket connection where possible. This is non-trivial to implement — use SharedWorker for tab coordination, or accept the multiple connections and handle deduplication server-side.

Backpressure

Backpressure is what happens when your server produces messages faster than a client can consume them. The TCP send buffer fills, the connection stalls, and eventually either the server runs out of memory buffering messages or the connection drops.

The browser's WebSocket implementation has no backpressure signaling back to your application code. websocket.bufferedAmount tells you how many bytes are queued for sending, but the browser doesn't pause your sends or notify you to slow down.

The correct pattern is to check bufferedAmount before sending high-frequency messages:

function safeSend(ws: WebSocket, message: string): boolean {
  const MAX_BUFFER = 64 * 1024; // 64KB
  if (ws.bufferedAmount > MAX_BUFFER) {
    // Client is behind — drop or queue this message
    return false;
  }
  ws.send(message);
  return true;
}

For SSE, the server-side stream provides natural backpressure through the Node.js stream API. If the client's connection is slow, the response.write() call will block until the buffer drains. Structure your SSE producer as an async generator and let the stream backpressure propagate:

async function* generateEvents(source: EventSource): AsyncGenerator<string> {
  for await (const event of source) {
    yield `data: ${JSON.stringify(event)}\n\n`;
  }
}

// In your request handler
for await (const chunk of generateEvents(source)) {
  const canContinue = response.write(chunk);
  if (!canContinue) {
    await new Promise(resolve => response.once('drain', resolve));
  }
}

Client Reconnection Strategies

Connections drop. Networks are unreliable. Mobile clients switch between WiFi and cellular. The question is not whether your client will need to reconnect — it's whether it does so correctly when it does.

The requirements for a correct reconnection strategy:

  1. Exponential backoff with jitter to avoid thundering herd when a server restarts
  2. A maximum retry count or timeout to avoid infinite retry loops
  3. State reconciliation after reconnection — the client may have missed messages while disconnected
  4. User notification when the connection has been down for a meaningful duration
class ReconnectingWebSocket {
  private ws: WebSocket | null = null;
  private retryCount = 0;
  private readonly maxRetries = 10;
  private readonly baseDelay = 1000; // 1s

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

    this.ws.onclose = () => {
      if (this.retryCount < this.maxRetries) {
        const delay = Math.min(
          this.baseDelay * Math.pow(2, this.retryCount) + Math.random() * 1000,
          30000 // cap at 30s
        );
        setTimeout(() => this.connect(url), delay);
        this.retryCount++;
      } else {
        this.onMaxRetriesReached?.();
      }
    };

    this.ws.onopen = () => {
      this.retryCount = 0;
      this.onReconnect?.(this.retryCount > 0);
    };
  }
}

SSE handles reconnection automatically through the browser's built-in reconnection behavior. The browser re-requests the SSE endpoint with a Last-Event-ID header containing the last received event's ID. The server uses this to replay missed events. Design your event IDs as monotonic cursors (timestamps, sequence numbers) to make replay straightforward.

Optimistic UI Patterns

Optimistic UI applies a state update immediately in the client before the server confirms it. The user sees instant feedback; the server confirmation arrives in the background. If the server rejects the update, the client rolls back.

type OptimisticUpdate<T> = {
  id: string; // stable ID for this update
  optimisticValue: T;
  rollback: () => void;
  confirmedAt?: number;
};

function useOptimisticList<T extends { id: string }>(initialItems: T[]) {
  const [items, setItems] = useState(initialItems);
  const pending = useRef<Map<string, OptimisticUpdate<T>>>(new Map());

  const add = useCallback((item: T) => {
    // Apply optimistically
    setItems(prev => [item, ...prev]);

    const update: OptimisticUpdate<T> = {
      id: item.id,
      optimisticValue: item,
      rollback: () => setItems(prev => prev.filter(i => i.id !== item.id)),
    };
    pending.current.set(item.id, update);

    return {
      confirm: () => {
        update.confirmedAt = Date.now();
        pending.current.delete(item.id);
      },
      reject: (error: Error) => {
        update.rollback();
        pending.current.delete(item.id);
        reportError(error);
      },
    };
  }, []);

  return { items, add };
}

Conflict Resolution

Optimistic updates create conflict scenarios: user A applies update X, user B applies update Y simultaneously, and the server state after both is ambiguous. The conflict resolution strategy must be chosen deliberately, not discovered in production.

Three strategies with real tradeoffs:

Last-write-wins (LWW): The most recent server-side write overwrites earlier ones. Simple to implement. Correct when writes to the same field are independent (like updating a user's avatar). Disastrous for collaborative text editing where concurrent writes to the same field should be merged.

Operational transformation (OT): Operations are transformed against each other so that concurrent writes compose correctly. Powers Google Docs. Complex to implement correctly. Suitable when operations are granular (character insertions/deletions) and composable.

Conflict-free replicated data types (CRDTs): Data structures that converge to the same state regardless of the order operations are applied. Suitable for counters, sets, and maps where the merge semantics are well-defined. Yjs is the de facto standard CRDT library for collaborative text. Not suitable for operations with complex business logic.

For most production systems, LWW with a clear "who wins" policy is the starting point. Document the conflict policy explicitly — "the server's version always wins, the client roll backs" or "the client's optimistic state is authoritative until the server explicitly rejects it." Undocumented conflict policies become bugs that are extremely difficult to reproduce and diagnose.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP