Webhooks, SSE, and WebSockets: Choosing the Right Real-Time Pattern
Your dashboard shows order status. You poll the API every 5 seconds. 99% of those polls return “no change.” You are making 17,280 API calls per day per user to detect a handful of status changes. Your API servers are handling millions of unnecessary requests. Your users still see a 5-second delay when an order ships.
There is a better way. The server should tell the client when something changes, not the other way around.
The polling problem
Polling is the naive solution to real-time updates: the client asks “anything new?” on a schedule. It is simple to implement but wasteful:
- Unnecessary load - Most polls return nothing
- Latency - Updates are delayed by up to the poll interval
- Scalability - 10,000 clients polling every second = 10,000 requests/second for no reason
Long polling is a variant: the client makes a request, the server holds it open until there is data or a timeout. Better than short polling but still has overhead.
The right solutions: webhooks, SSE, or WebSockets - depending on your use case.
Webhooks: server-to-server push
A webhook is an HTTP callback. When an event occurs, your server makes an HTTP POST request to a URL the client has registered. The client receives the event and processes it.
How it works:
- Client registers a webhook URL:
POST /webhooks {url: "https://client.com/webhook", events: ["order.shipped"]} - Event occurs on your server (order ships)
- Your server POSTs the event to
https://client.com/webhook - Client processes the event and returns 200 OK
What webhooks are good at:
- Server-to-server event delivery
- Asynchronous event processing
- No persistent connection needed
- Works with any HTTP server
What webhooks struggle with:
- Client must have a publicly accessible URL (hard for local development)
- Delivery failures require retry logic
- No ordering guarantees
- Client must handle duplicate delivery (at-least-once delivery)
graph LR subgraph webhook["Webhook Flow"] E["Event occurs Order shipped"] S["Your server"] Q["Retry queue with backoff"] C["Client server https://client.com/webhook"] E --> S S -->|"POST event"| C C -->|"200 OK"| S S -->|"delivery failed"| Q Q -->|"retry after 1min, 5min, 30min"| C end style E fill:#EEEDFE,stroke:#534AB7,color:#3C3489 style Q fill:#FAEEDA,stroke:#854F0B,color:#633806 style C fill:#E1F5EE,stroke:#0F6E56,color:#085041
Webhook reliability patterns
Retry with exponential backoff - If delivery fails, retry after 1 minute, then 5 minutes, then 30 minutes, then give up. Store failed deliveries for manual replay.
Idempotency keys - Include a unique event ID in every webhook. The client stores processed event IDs and ignores duplicates. This handles the case where the client processed the event but the 200 OK was lost, causing a retry.
Signature verification - Sign webhook payloads with HMAC-SHA256 using a shared secret. The client verifies the signature before processing. This prevents malicious actors from sending fake webhooks to your client’s URL.
Webhook ordering - Events may arrive out of order due to retries. Include a sequence number or timestamp. The client should handle out-of-order delivery.
Server-Sent Events (SSE): server-to-browser streaming
SSE is a one-way streaming protocol over HTTP. The server sends a stream of events to the browser. The browser receives them in real time. The connection stays open.
How it works:
- Browser opens a connection:
GET /eventswithAccept: text/event-stream - Server keeps the connection open and sends events as they occur
- Browser receives events and fires JavaScript event listeners
- If the connection drops, the browser automatically reconnects
SSE event format:
data: {"type": "order.shipped", "orderId": "123"}
data: {"type": "order.delivered", "orderId": "123"}
What SSE is good at:
- Real-time updates to browsers (no JavaScript library needed)
- Automatic reconnection built into the browser
- Works over HTTP/1.1 and HTTP/2
- Simple to implement on the server
- Works through proxies and firewalls (it is just HTTP)
What SSE struggles with:
- One-way only (server to client)
- HTTP/1.1 has a 6-connection-per-domain limit (use HTTP/2 to avoid this)
- Not suitable for bidirectional communication
Best for: Live dashboards, news feeds, notifications, progress updates, stock prices.
WebSockets: bidirectional real-time communication
WebSockets provide a full-duplex communication channel over a single TCP connection. Both client and server can send messages at any time.
How it works:
- Client sends an HTTP upgrade request:
Upgrade: websocket - Server responds with
101 Switching Protocols - The TCP connection is now a WebSocket connection
- Both sides can send messages at any time
- Either side can close the connection
What WebSockets are good at:
- Bidirectional communication (client and server both send)
- Low latency (no HTTP overhead per message)
- Real-time collaborative applications
- Chat, gaming, live collaboration
What WebSockets struggle with:
- Stateful connections (each connection is tied to a specific server)
- Load balancing requires sticky sessions or a pub/sub layer
- Proxies and firewalls sometimes block WebSocket connections
- More complex to implement than SSE
graph TB subgraph comparison["Comparison"] WH["Webhooks Server to server HTTP POST Async delivery Retry logic needed"] SSE2["SSE Server to browser HTTP streaming Auto-reconnect One-way only"] WS["WebSockets Bidirectional TCP connection Low latency Stateful"] end subgraph use["Best Used For"] U1["Payment events CI/CD notifications Third-party integrations"] U2["Live dashboards Notifications Progress bars News feeds"] U3["Chat Collaborative editing Multiplayer games Live trading"] end WH --- U1 SSE2 --- U2 WS --- U3 style WH fill:#EEEDFE,stroke:#534AB7,color:#3C3489 style SSE2 fill:#E1F5EE,stroke:#0F6E56,color:#085041 style WS fill:#FAEEDA,stroke:#854F0B,color:#633806
Where it breaks or gets interesting
WebSocket scaling with pub/sub
WebSocket connections are stateful - a connection is tied to a specific server. If you have 10 servers and a message needs to go to a user connected to server 3, how does server 1 deliver it?
Solution: pub/sub layer. When server 1 needs to send a message to user X, it publishes to a channel (e.g., user:X). All servers subscribe to all user channels. Server 3, which holds user X’s connection, receives the message and delivers it.
Redis pub/sub is the common choice. For higher scale, use Kafka or a dedicated message broker.
SSE and HTTP/2
HTTP/1.1 limits browsers to 6 connections per domain. If you have 6 SSE connections open, no other requests can be made to that domain. HTTP/2 multiplexes multiple streams over one connection, eliminating this limit. Use HTTP/2 for SSE in production.
Webhook fan-out
If you have 10,000 clients subscribed to an event, delivering the webhook to all of them requires 10,000 HTTP requests. This is a fan-out problem. Use a queue (SQS, Kafka) to distribute the delivery work across multiple workers. Do not deliver webhooks synchronously in the request handler.
Connection limits
Each WebSocket or SSE connection consumes a file descriptor on the server. A typical Linux server has a default limit of 65,535 file descriptors. With 10,000 concurrent connections, you are using 15% of your limit. Increase the limit (ulimit -n) and use an event-driven server (Node.js, Go, nginx) that handles many connections efficiently without a thread per connection.
Real-world systems
Stripe - Webhooks for payment events. Signed with HMAC-SHA256. Retry with exponential backoff. Dashboard for viewing and replaying webhook deliveries.
GitHub - Webhooks for repository events (push, pull request, issue). Configurable per repository or organization.
Slack - WebSockets for real-time message delivery to clients. SSE as a fallback. Webhooks for incoming messages from external services.
Figma - WebSockets for real-time collaborative editing. Multiple users editing the same file see each other’s changes in real time.
Vercel - SSE for deployment logs. The browser streams build output in real time as the deployment runs.
Binance - WebSockets for real-time market data (order book updates, trade feeds). Millions of concurrent connections.
How to apply it in practice
Decision framework
Use webhooks when:
- Delivering events to another server (not a browser)
- Events are infrequent and asynchronous
- The client needs to process events independently of the user session
- You need reliable delivery with retry logic
Use SSE when:
- Pushing updates to a browser
- Communication is one-way (server to client)
- You want simplicity (no library needed, automatic reconnect)
- Updates are relatively infrequent (not thousands per second)
Use WebSockets when:
- Bidirectional communication is needed
- Low latency is critical (chat, gaming)
- High message frequency (trading, live collaboration)
- The client needs to send data to the server in real time
Implementing webhooks correctly
- Deliver asynchronously (use a queue, not synchronous HTTP calls)
- Retry with exponential backoff (1min, 5min, 30min, 2h, 24h)
- Sign payloads with HMAC-SHA256
- Include a unique event ID for idempotency
- Provide a dashboard for viewing and replaying deliveries
- Document the event schema and provide test events
FAQ
Q: When should you use polling instead of webhooks/SSE/WebSockets?
Polling is appropriate when: the update frequency is very low (once per hour), the client cannot receive push notifications (no public URL for webhooks, no persistent connection), or simplicity is more important than efficiency. For most real-time use cases, polling is the wrong choice. But for a dashboard that updates once per minute, polling every 30 seconds is simpler than maintaining a WebSocket connection.
Q: How do you test webhooks locally?
Your local server does not have a public URL. Solutions: use a tunneling service (ngrok, Cloudflare Tunnel) that creates a public URL that forwards to your local server. Or use a webhook testing service (webhook.site) that captures webhooks and lets you inspect them. Or mock the webhook delivery in your tests by calling your webhook handler directly.
Q: What is the difference between SSE and WebSockets for a notification system?
For a notification system (server pushes notifications to the browser), SSE is simpler and sufficient. It is one-way (server to client), which is all you need. SSE has automatic reconnection built into the browser. WebSockets are overkill for this use case - they add complexity (stateful connections, pub/sub layer) without benefit. Use WebSockets only when the client also needs to send data to the server in real time (chat, collaborative editing, gaming).
Interview questions
Q1: Design a real-time order tracking system. Users should see their order status update in real time without refreshing the page.
Strong answer: Use SSE for the browser-to-server connection. When a user opens the order tracking page, the browser opens an SSE connection to /orders/123/events. The server keeps the connection open. When the order status changes (picked up, in transit, delivered), the server sends an SSE event. The browser updates the UI. On the server side: when an order status changes, publish an event to a Redis pub/sub channel (order:123). The SSE handler subscribes to this channel and forwards events to the connected browser. If the user closes the tab and reopens it, the browser reconnects and the server sends the current status. This is simpler than WebSockets (one-way communication is sufficient) and more efficient than polling.
Q2: You are building a webhook delivery system for a payment platform. How do you ensure reliable delivery?
Strong answer: Use a queue-based delivery system. When an event occurs, publish it to a queue (SQS or Kafka). Workers consume from the queue and attempt delivery. If delivery fails (non-2xx response or timeout), the worker puts the event back in the queue with a delay (exponential backoff: 1min, 5min, 30min, 2h, 24h). After N failures, move to a dead letter queue for manual inspection. Include a unique event ID in every webhook payload. Clients should store processed event IDs and ignore duplicates (idempotent processing). Sign payloads with HMAC-SHA256 so clients can verify authenticity. Provide a dashboard showing delivery status, response codes, and the ability to replay failed deliveries. Log all delivery attempts for debugging.
Q3: Your WebSocket server handles 100,000 concurrent connections. You need to deploy a new version without dropping connections. How do you do this?
Strong answer: Use a rolling deployment with connection draining. Before taking a server out of rotation: stop accepting new WebSocket connections (remove from load balancer), wait for existing connections to close naturally or after a timeout (30-60 seconds), then deploy the new version. For connections that do not close naturally, send a close frame with a “server restarting” message so clients reconnect to a different server. The client should implement automatic reconnection with exponential backoff. The pub/sub layer (Redis) ensures that after reconnection, the client is routed to a different server and continues receiving messages. For zero-downtime, use blue-green deployment: bring up new servers, gradually shift new connections to them, drain old servers.