Real-Time Event Delivery Patterns

A comparison of real-time event delivery mechanisms including WebSockets, Server-Sent Events, and long polling, with implementation examples and guidance on choosing the right approach.

technical6 min readBy Klivvr Engineering
Share:

Real-time event delivery is the mechanism by which servers push data to clients without the client explicitly requesting it. Three primary approaches exist: WebSockets, Server-Sent Events (SSE), and long polling. Each has different characteristics in terms of complexity, browser support, proxy compatibility, and suitability for different use cases.

Whisper uses all three mechanisms depending on the client context. This article compares them with working implementations and guidance on when to use each.

Long Polling

Long polling is the simplest server-push mechanism. The client sends a regular HTTP request, and the server holds the connection open until it has data to send. When data arrives (or a timeout expires), the server responds, and the client immediately sends another request.

// Server: long polling endpoint
import express from "express";
 
const app = express();
const pendingRequests = new Map<string, express.Response>();
 
app.get("/api/events/:userId", async (req, res) => {
  const { userId } = req.params;
  const timeout = parseInt(req.query.timeout as string) || 30000;
 
  // Check for immediately available events
  const events = await eventStore.getPending(userId);
  if (events.length > 0) {
    res.json({ events, hasMore: false });
    return;
  }
 
  // Hold connection until event arrives or timeout
  pendingRequests.set(userId, res);
 
  const timer = setTimeout(() => {
    pendingRequests.delete(userId);
    res.json({ events: [], hasMore: false });
  }, timeout);
 
  req.on("close", () => {
    clearTimeout(timer);
    pendingRequests.delete(userId);
  });
});
 
// When a new event arrives for a user
async function notifyUser(userId: string, event: unknown): Promise<void> {
  const pending = pendingRequests.get(userId);
  if (pending) {
    pendingRequests.delete(userId);
    pending.json({ events: [event], hasMore: false });
  }
}
// Client: long polling consumer
class LongPollingClient {
  private active = false;
  private baseUrl: string;
 
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }
 
  start(userId: string, onEvent: (event: unknown) => void): void {
    this.active = true;
    this.poll(userId, onEvent);
  }
 
  stop(): void {
    this.active = false;
  }
 
  private async poll(
    userId: string,
    onEvent: (event: unknown) => void
  ): Promise<void> {
    while (this.active) {
      try {
        const response = await fetch(
          `${this.baseUrl}/api/events/${userId}?timeout=30000`
        );
        const data = await response.json();
 
        for (const event of data.events) {
          onEvent(event);
        }
      } catch (error) {
        // Wait before retrying on error
        await new Promise((r) => setTimeout(r, 5000));
      }
    }
  }
}

Long polling works everywhere — any HTTP client supports it. But it has overhead: each poll cycle creates a new HTTP connection with full headers, and the server holds connections that consume resources even when no data is flowing.

Server-Sent Events

SSE provides a standardized, unidirectional channel from server to client over HTTP. The server sends a stream of events over a single long-lived connection. SSE handles reconnection automatically and supports event IDs for resuming after disconnection.

// Server: SSE endpoint
app.get("/api/stream/:userId", (req, res) => {
  const { userId } = req.params;
 
  // Set SSE headers
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no",  // Disable nginx buffering
  });
 
  // Send initial connection event
  res.write(`event: connected\ndata: ${JSON.stringify({ userId })}\n\n`);
 
  // Register this connection for events
  const listener = (event: NotificationEvent) => {
    res.write(
      `id: ${event.id}\n` +
      `event: ${event.type}\n` +
      `data: ${JSON.stringify(event.payload)}\n\n`
    );
  };
 
  eventEmitter.on(`user:${userId}`, listener);
 
  // Heartbeat to keep connection alive
  const heartbeat = setInterval(() => {
    res.write(": heartbeat\n\n");
  }, 15000);
 
  // Handle client disconnect
  req.on("close", () => {
    clearInterval(heartbeat);
    eventEmitter.off(`user:${userId}`, listener);
  });
});
// Client: SSE consumer (browser-native)
class SSEClient {
  private eventSource: EventSource | null = null;
 
  connect(
    userId: string,
    handlers: Record<string, (data: unknown) => void>
  ): void {
    this.eventSource = new EventSource(
      `/api/stream/${userId}`
    );
 
    this.eventSource.onopen = () => {
      console.log("SSE connection established");
    };
 
    // Register event handlers
    for (const [eventType, handler] of Object.entries(handlers)) {
      this.eventSource.addEventListener(eventType, (event) => {
        const data = JSON.parse((event as MessageEvent).data);
        handler(data);
      });
    }
 
    this.eventSource.onerror = (error) => {
      console.error("SSE error, will auto-reconnect:", error);
      // EventSource automatically reconnects
    };
  }
 
  disconnect(): void {
    this.eventSource?.close();
    this.eventSource = null;
  }
}
 
// Usage
const sse = new SSEClient();
sse.connect("user_123", {
  transaction_alert: (data) => showTransactionAlert(data),
  notification: (data) => showNotification(data),
  balance_update: (data) => updateBalance(data),
});

SSE is simpler than WebSockets — it uses standard HTTP, works through most proxies without configuration, and the browser handles reconnection automatically. The limitation is that it is unidirectional: the server pushes to the client, but the client cannot send messages back through the same channel.

WebSockets vs SSE vs Long Polling

The choice depends on the use case:

Use WebSockets when you need bidirectional communication (chat, interactive features), the client sends frequent messages to the server, or you need the lowest possible latency.

Use SSE when communication is primarily server-to-client, you want automatic reconnection with event ID resumption, or you need to work through HTTP proxies that do not support WebSocket upgrade.

Use long polling when you need maximum compatibility (older browsers, restricted networks), event frequency is low (minutes between events), or the infrastructure does not support persistent connections.

Whisper uses WebSockets as the primary channel for mobile apps (where the SDK controls the connection), SSE for web dashboard real-time updates, and long polling as a fallback when WebSocket and SSE connections fail.

Adaptive Transport Selection

Whisper's client SDK automatically selects the best available transport based on the client's capabilities and network conditions.

class AdaptiveTransport {
  private transports: Transport[] = [];
  private activeTransport: Transport | null = null;
 
  constructor() {
    // Ordered by preference
    this.transports = [
      new WebSocketTransport(),
      new SSETransport(),
      new LongPollingTransport(),
    ];
  }
 
  async connect(
    userId: string,
    onEvent: (event: unknown) => void
  ): Promise<void> {
    for (const transport of this.transports) {
      try {
        await transport.connect(userId, onEvent);
        this.activeTransport = transport;
        console.log(`Connected via ${transport.name}`);
 
        // Monitor connection health
        transport.onDisconnect(() => {
          this.fallbackToNext(userId, onEvent);
        });
 
        return;
      } catch (error) {
        console.warn(
          `${transport.name} failed, trying next:`,
          error
        );
      }
    }
 
    throw new Error("All transports failed");
  }
 
  private async fallbackToNext(
    userId: string,
    onEvent: (event: unknown) => void
  ): Promise<void> {
    const currentIndex = this.transports.indexOf(this.activeTransport!);
    const remaining = this.transports.slice(currentIndex + 1);
 
    for (const transport of remaining) {
      try {
        await transport.connect(userId, onEvent);
        this.activeTransport = transport;
        return;
      } catch {
        continue;
      }
    }
 
    // All transports exhausted — retry from the beginning after delay
    await new Promise((r) => setTimeout(r, 5000));
    await this.connect(userId, onEvent);
  }
 
  disconnect(): void {
    this.activeTransport?.disconnect();
    this.activeTransport = null;
  }
}

Conclusion

Real-time event delivery is not a one-size-fits-all problem. WebSockets provide the most capable bidirectional channel. SSE offers simplicity and automatic reconnection for server-to-client streaming. Long polling provides universal compatibility at the cost of higher overhead. Whisper uses all three, selecting the appropriate transport based on client capabilities, network conditions, and use case requirements. The adaptive transport layer ensures that users receive real-time notifications regardless of their client environment.

Related Articles

business

Omnichannel Messaging Strategy for Fintech

How to build a unified omnichannel messaging strategy across push, SMS, email, and in-app channels — covering channel selection, message consistency, and unified customer experience.

7 min read
business

Real-Time Notifications in Banking

How real-time notifications transform the banking experience, covering transaction alerts, security notifications, compliance requirements, and the business impact of instant communication.

6 min read
technical

Scaling Notification Systems

Strategies for scaling notification systems to millions of users, covering horizontal scaling, fan-out patterns, rate limiting, and infrastructure design — with patterns from Whisper.

5 min read