DEV Community

Cover image for Event-Driven Design: Building Real-Time Streams with WebSockets and Webhooks
Fuad Husnan
Fuad Husnan

Posted on

Event-Driven Design: Building Real-Time Streams with WebSockets and Webhooks

Event-driven design turns your backend from a system that waits to be asked into one that reacts as things happen. Instead of clients polling an endpoint every few seconds to check "did anything change yet," the server pushes updates the moment they occur. Two mechanisms do most of the heavy lifting here: WebSockets and webhooks. They solve different halves of the same problem, and most production systems that claim to be "real-time" are actually running both at once.

This guide breaks down how each one works, when to reach for it, and how to wire them together without turning your event pipeline into a source of on-call pages.

Why Polling Doesn't Scale

Before event-driven patterns took over, the default was polling: a client hits an API every few seconds, checks a timestamp or a status field, and does nothing 95% of the time. It works, but it wastes requests, adds latency proportional to your polling interval, and gets expensive fast once you have thousands of clients doing it simultaneously.

Event-driven architectures flip this. A backend receives an event, validates it, stores it, and hands it off to whatever needs to act on it. Polling still has a place as a fallback or reconciliation mechanism, but it's rarely the first choice once a system can push events instead of waiting to be asked.

The distinction that actually matters when picking your mechanism isn't "is this real-time" — both WebSockets and webhooks are real-time in the sense that they avoid polling delay. The distinction is who initiates the connection and whether that connection stays open.

WebSockets: Persistent, Bidirectional Connections

A WebSocket starts as an ordinary HTTP request. The client sends an Upgrade: websocket header, the server responds with HTTP 101 Switching Protocols, and from that point on the connection stops speaking HTTP entirely. What's left is a raw TCP socket with a thin framing layer — no headers per message, no request-response cycle, just a persistent, full-duplex pipe. Removing HTTP overhead from every message is what makes WebSockets so much cheaper per message than repeated HTTP calls.

That persistence is the whole value proposition. Once the handshake completes, either side can send data at any time without renegotiating a connection. This is why WebSockets are the natural fit for chat applications, live dashboards, multiplayer games, and collaborative editors — anywhere the client is a browser or mobile app that needs to both receive pushes and send messages back on the same channel.

Here's a minimal WebSocket server in Node.js using the ws library:

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (socket) => {
  console.log('Client connected');

  socket.on('message', (data) => {
    const event = JSON.parse(data);
    console.log('Received event:', event.type);

    // Broadcast to all connected clients
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({
          type: 'broadcast',
          payload: event.payload,
          timestamp: Date.now()
        }));
      }
    });
  });

  socket.on('close', () => {
    console.log('Client disconnected');
  });
});
Enter fullscreen mode Exit fullscreen mode

And a corresponding browser client:

const socket = new WebSocket('wss://your-server.com:8080');

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({
    type: 'subscribe',
    payload: { channel: 'order-updates' }
  }));
});

socket.addEventListener('message', (event) => {
  const data = JSON.parse(event.data);
  console.log('Update received:', data);
});
Enter fullscreen mode Exit fullscreen mode

The trade-off is that "always on" comes with real operational cost. A WebSocket server has to hold connection state in memory for every connected client, which means scaling horizontally requires a pub/sub layer so that a message published on one node reaches a client connected to a different node. You also have to handle reconnects, heartbeats to detect dead connections, and uneven client network conditions. Keeping state coherent across all of that under load is the actual hard part of "real-time" — the streaming itself is the easy half.

Webhooks: Stateless, One-Way Notifications

A webhook is the opposite shape. It's a plain HTTP POST request sent from one application to another when a specific event happens. There's no persistent connection and no handshake to maintain — each event is an independent, stateless HTTP call. The source system decides something happened, fires a request at a URL you registered in advance, and moves on. Your server receives it, acknowledges it, and does whatever it needs to do with the payload.

This makes webhooks the right tool whenever the receiving side doesn't need to talk back on the same channel — payment confirmations, repository push notifications, CRM record updates, or any server-to-server event where "tell me when X happens" is the entire requirement. Webhooks don't help when the client is a browser that can't expose a public endpoint of its own, and they're inherently one-directional, which is exactly why they pair so well with WebSockets rather than replacing them.

A basic webhook receiver in Express looks like this:

const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

function verifySignature(req) {
  const signature = req.headers['x-webhook-signature'];
  const payload = JSON.stringify(req.body);
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.post('/webhooks/orders', (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const { event, data } = req.body;

  // Acknowledge immediately, process asynchronously
  res.status(200).json({ received: true });

  processOrderEvent(event, data).catch((err) => {
    console.error('Failed to process webhook:', err);
    // route to retry queue or dead-letter storage
  });
});

app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Two details in that snippet matter more than they look. First, signature verification with timingSafeEqual prevents timing attacks against your secret comparison — a plain === check leaks information about how many characters matched. Second, the handler responds before processing finishes. Webhook senders enforce timeouts and will retry on anything that looks like a failure, so slow synchronous processing inside the request handler is a common cause of duplicate deliveries.

That duplication risk is structural, not a bug you can code around. If your server doesn't respond fast enough, or the response is lost in transit, the sender assumes failure and retries — which means your endpoint needs to be idempotent. Track processed event IDs and skip anything you've already handled.

Fragmentation Is the Real Cost of Webhooks

The retry problem is solvable with idempotency keys. The harder problem is that every webhook provider has historically implemented its own signature scheme, retry policy, and payload shape. Handling ten providers has meant writing ten different verifiers and ten different retry assumptions.

The Standard Webhooks specification exists specifically to close that gap. It defines a common signing scheme, delivery format, and verification approach so that consumers don't have to relearn webhook handling for every new integration. As of 2026, it has been adopted by a range of companies including OpenAI, Anthropic, Google Gemini, Twilio, PagerDuty, and Supabase, among others — worth checking for before you write yet another one-off signature verifier from scratch.

On the payload side, CloudEvents has become the closest thing to a vendor-neutral standard for structuring the event body itself:

{
  "specversion": "1.0",
  "type": "com.example.order.created",
  "source": "/orders/service",
  "id": "A234-1234-1234",
  "time": "2026-01-25T17:31:00Z",
  "datacontenttype": "application/json",
  "data": {
    "orderId": "12345",
    "amount": 99.99
  }
}
Enter fullscreen mode Exit fullscreen mode

If you're building a system that emits webhooks to your own customers, aligning early with Standard Webhooks for delivery and CloudEvents for payload shape saves you from designing a bespoke format that every integrator has to learn from scratch.

Combining Both in One Architecture

Most systems that need to feel real-time end up running WebSockets and webhooks side by side rather than choosing one. A typical e-commerce flow illustrates why: a payment provider fires a webhook when a charge succeeds, your backend updates the order record, and then that update needs to reach the customer's browser instantly. That last leg is a WebSocket push, not another webhook, because the browser has no public endpoint to receive one.

const express = require('express');
const WebSocket = require('ws');

const app = express();
app.use(express.json());

const wss = new WebSocket.Server({ port: 8080 });
const clientsByOrder = new Map(); // orderId -> Set of sockets

wss.on('connection', (socket, req) => {
  const orderId = new URL(req.url, 'http://localhost').searchParams.get('orderId');
  if (!clientsByOrder.has(orderId)) {
    clientsByOrder.set(orderId, new Set());
  }
  clientsByOrder.get(orderId).add(socket);

  socket.on('close', () => clientsByOrder.get(orderId)?.delete(socket));
});

app.post('/webhooks/payment', express.json(), (req, res) => {
  const { orderId, status } = req.body;
  res.status(200).json({ received: true });

  updateOrderStatus(orderId, status).then(() => {
    const subscribers = clientsByOrder.get(orderId);
    if (subscribers) {
      const message = JSON.stringify({ type: 'order-status', orderId, status });
      subscribers.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) client.send(message);
      });
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

This pattern — webhook in, database update, WebSocket push out — is the backbone of most "live" dashboards you've used. The webhook handles the reliable, asynchronous, server-to-server leg. The WebSocket handles the low-latency leg to a connected browser. Neither one replaces the other; they're doing different jobs in the same pipeline.

Operational Realities Worth Planning For

Building the happy path for either mechanism takes an afternoon. Making it production-grade is where the real time goes. For webhooks, budget for retry queues, dead-letter handling, and a delivery dashboard so you can see what failed and why — this consistently takes longer than teams expect, which is exactly why platforms like Svix, Hookdeck, and Convoy exist to take it off your plate if you're sending webhooks to your own customers, or Nango and similar tools if you're receiving them from external providers.

For WebSockets, the equivalent tax is connection lifecycle management: heartbeats to detect half-open connections, reconnection logic on the client, and a pub/sub layer (Redis, NATS, or a managed service) so that broadcasts reach clients regardless of which server instance they're connected to. None of this is optional past a handful of concurrent users — it's the difference between a demo and a system that survives a network blip.

Choosing Between Them

The decision comes down to three questions. Does the receiving side need to send data back on the same channel, or is a one-way notification enough? Can the receiver expose a public HTTP endpoint, or is it a browser/mobile client sitting behind NAT? And does the interaction represent a continuous session, or a series of discrete events with gaps between them?

A trading terminal or multiplayer game is a continuous session — WebSockets. A payment confirmation or repository update is a discrete, one-way event — a webhook. Most real systems have both kinds of interaction happening simultaneously, which is why the architectures that hold up under load treat WebSockets and webhooks as complementary tools rather than competing choices.

If you're starting from scratch, don't build the retry infrastructure or the connection-scaling layer yourself before you need to. Start with the standard patterns above, adopt Standard Webhooks and CloudEvents where they fit, and reach for dedicated infrastructure once your event volume justifies the operational overhead of running it in-house.

Top comments (0)