Stream Webhooks to Your Browser in Real-Time with SSE and Node.js

by Fahim

I got tired of constantly refreshing my database GUI and tailing terminal logs just to see if a webhook payload actually arrived. When you’re building integrations, you need to see exactly what external services are sending you, right as it happens.

Most developers jump straight to WebSockets for real-time updates. But WebSockets are overkill here. They’re bi-directional, require custom protocols, and can be a massive pain to configure behind reverse proxies. If you just need to push incoming webhook data from your server to your browser UI, Server-Sent Events (SSE) are a much simpler, native choice.

We’re going to build a lightweight Node.js Express server that accepts incoming webhooks and streams them instantly to a clean browser dashboard. No heavy external libraries, just native browser APIs.

Real-time webhook stream dashboard showing JSON payload and green connection badge next to a terminal
Real-time webhook stream dashboard showing JSON payload and green connection badge next to a terminal

Why I Chose SSE Over WebSockets

WebSockets are great for collaborative whiteboards or chat apps where the client and server talk back and forth constantly. But webhooks are strictly one-way traffic: an external service calls your server, and your server needs to tell your browser tab what happened. That’s it.

SSE shines here because it runs over standard HTTP using a single, long-lived connection. Here is why I prefer it for webhook debugging tools:

  • Native Browser Support: You don’t need socket.io or some bloated client-side wrapper. The browser has a built-in EventSource API that handles everything out of the box.
  • Automatic Reconnection: If the network drops, the browser automatically tries to reconnect. You don’t have to write custom setInterval retry loops.
  • Firewall Friendly: It runs over standard HTTP/HTTPS, so it easily bypasses strict corporate firewalls that often block raw WebSocket protocols.

Setting Up the Project

Let’s set up a clean, minimal directory. I hate package bloat, so we’re keeping dependencies to an absolute minimum.

Create a new directory and initialize your project:

mkdir webhook-sse-streamer
cd webhook-sse-streamer
npm init -y
npm install express cors

We only need express for routing and cors so our frontend can connect without issues. Next, create two files: server.js and index.html.

Building the Express Server with SSE

The magic of SSE lies entirely in the HTTP headers. We have to tell the browser to keep the connection open and treat the response as an event stream. Open up server.js and let’s write the basic Express setup.

const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 4000; app.use(cors());
app.use(express.json());
app.use(express.static('.')); let clients = []; app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`);
});

Now for the SSE endpoint. When the browser loads our UI, it will open a connection to /events. We need to set specific headers, add the client connection to an array so we can broadcast to it later, and clean up when the user closes the tab.

app.get('/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write('data: {"status": "connected"}nn'); const clientId = Date.now(); const newClient = { id: clientId, res }; clients.push(newClient); req.on('close', () => { console.log(`Client ${clientId} disconnected`); clients = clients.filter(client => client.id !== clientId); });
});

Pay close attention to the double newlines (nn) at the end of the res.write call. The SSE protocol relies on these to know when an event payload actually ends. If you forget them, your browser will just sit there waiting forever, and your event listener will never trigger. I’ve lost hours to this exact typo.

Creating the Webhook Receiver Endpoint

Now that we’re tracking clients, we need an endpoint to receive incoming POST requests from third-party services. When a webhook hits this route, we’ll parse the payload and stream it straight to all connected browser tabs.

If you want to make sure your incoming webhooks are structured correctly before broadcasting them, you can validate webhook payloads with Zod in Express to catch errors early.

Here’s how we accept the webhook payload and broadcast it to our SSE clients:

app.post('/webhook', (req, res) => { const webhookPayload = { timestamp: new Date().toISOString(), headers: req.headers, body: req.body, query: req.query }; console.log('Received webhook:', JSON.stringify(webhookPayload, null, 2)); clients.forEach(client => { client.res.write(`data: ${JSON.stringify(webhookPayload)}nn`); }); res.status(200).json({ success: true, message: 'Webhook broadcasted' });
});

Every time a POST request hits /webhook, we package the headers, query parameters, and JSON body. Then we loop through our clients array and write the stringified data to each open HTTP connection.

Building the Frontend Viewer

Now we need a clean UI to display these webhooks in real-time. We’ll build a simple HTML page that uses the browser’s native EventSource object. No React, no build steps, just clean HTML and vanilla JS.

Open up index.html and paste this in:

<!DOCTYPE html>
<html lang="en">
<head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Real-Time Webhook Streamer</title> <style> body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background-color: #121214; color: #e1e1e6; margin: 0; padding: 20px; } header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #29292e; padding-bottom: 10px; } .status { font-size: 0.9rem; padding: 4px 8px; border-radius: 4px; } .connected { background-color: #04d361; color: #000; } .disconnected { background-color: #f74040; color: #fff; } #events-list { margin-top: 20px; } .event-card { background-color: #202024; border: 1px solid #29292e; border-radius: 6px; padding: 15px; margin-bottom: 15px; } pre { background-color: #121214; padding: 10px; border-radius: 4px; overflow-x: auto; color: #50fa7b; } </style>
</head>
<body> <header> <h1>Live Webhook Stream</h1> <span id="status-badge" class="status disconnected">Disconnected</span> </header> <div id="events-list"></div> <script> const statusBadge = document.getElementById('status-badge'); const eventsList = document.getElementById('events-list'); const eventSource = new EventSource('/events'); eventSource.onopen = () => { statusBadge.textContent = 'Connected'; statusBadge.className = 'status connected'; }; eventSource.onerror = () => { statusBadge.textContent = 'Disconnected'; statusBadge.className = 'status disconnected'; }; eventSource.onmessage = (event) => { const data = JSON.parse(event.data); if (data.status === 'connected') return; const card = document.createElement('div'); card.className = 'event-card'; card.innerHTML = ` <p><strong>Received at:</strong> ${data.timestamp}</p> <p><strong>Payload:</strong></p> <pre><code>${JSON.stringify(data.body, null, 2)}</code></pre> `; eventsList.insertBefore(card, eventsList.firstChild); }; </script>
</body>
</html>

This UI prepends incoming payloads to the top of our list so you always see the latest webhook instantly. The browser’s native EventSource handles the connection to our backend automatically under the hood.

Testing the Stream Locally

Let’s spin up our Express server and test this out. Start the server in your terminal:

node server.js

Open your browser and head to http://localhost:4000. You should see the status badge turn green, showing “Connected”.

Now, open a second terminal window. We’ll trigger a fake webhook using curl to verify that our SSE connection is broadcasting live data:

curl -X POST http://localhost:4000/webhook -H "Content-Type: application/json" -d '{"event": "user.created", "user": {"id": 99, "email": "test@example.com"}}'

As soon as you hit Enter on that curl command, look back at your browser tab. The JSON payload will instantly pop up on the page without you needing to refresh. If you want to test this with real external webhooks on your local machine, you can test webhooks locally with Cloudflare Tunnels to expose your port securely.

Handling Connection Drops and Memory Leaks

In production, SSE connections will drop. Users will close tabs, reload pages, or lose Wi-Fi. If you don’t clean up your connections properly, your server will leak memory rapidly.

That’s why we handled the req.on('close') event in our server like this:

req.on('close', () => { clients = clients.filter(client => client.id !== clientId);
});

Without this filter block, the clients array would keep references to dead response objects forever. Over time, your Node process memory usage would climb until the OS eventually kills it.

If you’re routing webhooks at scale to multiple endpoints or multiple browser instances, you might want to read about how to route webhooks to multiple endpoints in Node.js to keep your architecture decoupled and clean.

Frequently Asked Questions

Does Server-Sent Events support POST requests?

No, the native browser EventSource API only supports GET requests. If you need to send complex configuration parameters to the server to initialize your stream, you’ll have to pass them as query parameters in the connection URL.

How many concurrent SSE connections can a browser handle?

If you’re on HTTP/1.1, browsers limit the number of open connections to the same domain to 6. If you open more than 6 tabs, your SSE connection will hang. However, if your server uses HTTP/2, this limit is negotiated dynamically, easily allowing up to 100 concurrent streams per domain.

Can I send binary data over SSE?

No, SSE is text-only. If you need to send binary payloads, you have to encode them in Base64 first. If your app relies heavily on binary streaming, WebSockets are definitely a better fit.

Next Steps

Now that you have a working real-time webhook streamer, you can build on top of this setup to make it more resilient. If you plan to deploy this to production, you should definitely look into securing your endpoints. Check out our guide on how to build a secure GoHighLevel webhook listener with Node.js to learn how to verify incoming requests using signatures and secret keys.

Official resources

all_in_one_marketing_tool