How to Use Server-Sent Events (SSE) in Express.js: Troubleshooting 'Can't Set Headers After They Are Sent' Error

In today’s web development landscape, real-time communication between clients and servers is no longer a luxury—it’s a necessity. From live dashboards to social media feeds, users expect instant updates without manually refreshing their browsers. While WebSockets are a popular choice for bidirectional communication, Server-Sent Events (SSE) offer a lightweight, unidirectional alternative ideal for scenarios where the server needs to push updates to the client (e.g., stock prices, live notifications).

However, implementing SSE in Express.js can sometimes lead to frustrating errors, with one of the most common being: “Can’t set headers after they are sent to the client”. This error occurs when the server attempts to modify HTTP headers after already sending a response, which breaks the SSE connection.

In this blog, we’ll demystify SSE, walk through setting it up in Express.js, and dive deep into troubleshooting the “Can’t set headers” error. By the end, you’ll have a solid understanding of SSE and how to avoid common pitfalls.

Table of Contents#

  1. What Are Server-Sent Events (SSE)?
  2. Prerequisites
  3. Setting Up an Express.js Project
  4. Implementing Basic SSE in Express.js
  5. Understanding the “Can’t Set Headers After They Are Sent” Error
  6. Common Causes of the Error
  7. Troubleshooting Steps
  8. Advanced SSE Tips
  9. Conclusion
  10. References

What Are Server-Sent Events (SSE)?#

Server-Sent Events (SSE) is a web standard that enables the server to send real-time updates to the client over a single, long-lived HTTP connection. Unlike WebSockets (which support bidirectional communication), SSE is unidirectional: data flows only from the server to the client.

Key Features of SSE:#

  • Lightweight: Uses HTTP/HTTPS, so it works with existing proxies and firewalls (no need for custom ports).
  • Automatic Reconnection: If the connection drops, the client (via EventSource) automatically reconnects.
  • Structured Data: Supports text-based data (e.g., plain text, JSON) with optional event types and IDs.

When to Use SSE:#

  • Live updates (e.g., news feeds, sports scores).
  • Real-time monitoring (e.g., server metrics, IoT data).
  • Notifications (e.g., email alerts, chat messages).

Avoid SSE for bidirectional communication (use WebSockets instead) or large binary data transfers.

Prerequisites#

Before we start, ensure you have:

  • Node.js (v14+ recommended) and npm installed.
  • Basic knowledge of Express.js and HTTP.
  • A code editor (e.g., VS Code).

Setting Up an Express.js Project#

Let’s start by creating a new Express.js project. Follow these steps:

Step 1: Initialize the Project#

mkdir express-sse-demo
cd express-sse-demo
npm init -y

Step 2: Install Dependencies#

We only need Express for this demo:

npm install express

Step 3: Create the Server File#

Create a server.js file in the project root:

const express = require('express');
const app = express();
const PORT = 3000;
 
app.get('/', (req, res) => {
  res.send('Hello, SSE!');
});
 
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

Run the server with node server.js and visit http://localhost:3000 to confirm it works.

Implementing Basic SSE in Express.js#

Now, let’s add an SSE endpoint to our Express server. SSE requires specific HTTP headers and a long-lived connection. Here’s how to set it up:

Step 1: Define the SSE Endpoint#

Add a new route (e.g., /sse) to handle SSE connections. The key is to set the correct headers and use res.write() to send events incrementally (instead of res.send() or res.json(), which close the connection).

app.get('/sse', (req, res) => {
  // Set SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache'); // Prevent caching
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*'); // Optional: for CORS
 
  // Send a welcome message
  res.write('data: Welcome to SSE!\n\n');
 
  // Simulate periodic updates (e.g., every 2 seconds)
  const interval = setInterval(() => {
    const time = new Date().toLocaleTimeString();
    res.write(`data: Current time: ${time}\n\n`); // SSE data format
  }, 2000);
 
  // Cleanup on client disconnect
  req.on('close', () => {
    clearInterval(interval);
    res.end(); // Close the connection
    console.log('Client disconnected');
  });
});

Step 2: Client-Side Implementation#

On the client (browser), use the EventSource API to connect to the SSE endpoint and listen for updates. Create an index.html file in the project root:

<!DOCTYPE html>
<html>
<body>
  <h1>Real-Time Updates</h1>
  <div id="updates"></div>
 
  <script>
    const eventSource = new EventSource('http://localhost:3000/sse');
 
    // Listen for 'message' events (default)
    eventSource.onmessage = (event) => {
      const updatesDiv = document.getElementById('updates');
      updatesDiv.innerHTML += `<p>${event.data}</p>`;
    };
 
    // Handle connection open
    eventSource.onopen = () => {
      console.log('SSE connection opened');
    };
 
    // Handle errors
    eventSource.onerror = (error) => {
      console.error('SSE error:', error);
      eventSource.close(); // Close on fatal errors
    };
  </script>
</body>
</html>

Step 3: Serve the Client File#

Update server.js to serve index.html from the root route:

const path = require('path'); // Add this at the top
 
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

Restart the server and visit http://localhost:3000. You’ll see real-time time updates every 2 seconds!

Understanding the “Can’t Set Headers After They Are Sent” Error#

The error “Can’t set headers after they are sent to the client” occurs when Express tries to modify HTTP headers after sending a response to the client. In SSE, this typically happens when:

Why It Happens:#

HTTP responses consist of two parts: headers (sent first) and body (sent next). Once headers are sent, they cannot be modified. In SSE, we use res.write() to send the body incrementally, but if the server attempts to send another response (e.g., res.send(), res.json(), or even res.setHeader()) after headers are already sent, Express throws this error.

Common Causes of the Error#

Let’s explore scenarios that trigger this error in SSE implementations:

1. Accidental Multiple Response Calls#

If your SSE route calls res.send(), res.json(), or res.end() after using res.write(), Express will try to send headers again, causing the error.

Example of Bad Code:

app.get('/sse', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.write('data: First message\n\n'); 
  res.send('Oops! This causes an error'); // ❌ Sends a second response
});

2. Middleware Sending Responses#

Express middleware (e.g., for authentication, logging) that calls res.send() or res.json() before reaching the SSE route will send headers early.

Example of Bad Middleware:

// Middleware that sends a response (problematic for SSE)
app.use((req, res, next) => {
  if (!req.user) {
    res.status(401).send('Unauthorized'); // ❌ Sends headers early
  }
  next();
});
 
app.get('/sse', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream'); // ❌ Headers already sent!
});

3. Unhandled Client Disconnections#

If the client disconnects but the server continues sending res.write() events, it may eventually attempt to send headers again when cleaning up.

4. Missing or Incorrect SSE Headers#

Forgetting to set critical headers (e.g., Content-Type: text/event-stream) can cause the server to treat the SSE response as a regular HTTP response, leading to premature header sending.

Troubleshooting Steps#

Follow these steps to fix the error:

1. Check for Multiple Response Calls#

Ensure your SSE route uses only res.write() for sending data. Avoid res.send(), res.json(), or res.end() (except in cleanup).

Fixed Code:

app.get('/sse', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.write('data: First message\n\n'); 
  // Do NOT use res.send() here! Use res.write() for updates.
});

2. Ensure Middleware Doesn’t Send Responses#

Middleware should only call next() to pass control to the next route/handler. Avoid sending responses in middleware that runs before SSE routes.

Fixed Middleware:

// Middleware that validates but doesn't send responses
app.use((req, res, next) => {
  if (!req.user && req.path === '/sse') {
    res.status(401).end(); // ✅ Send error and end early (before SSE headers)
    return; // Exit to avoid calling next()
  }
  next(); // ✅ Proceed to SSE route if valid
});

3. Handle Client Disconnections Gracefully#

Use req.on('close') to stop sending events when the client disconnects, preventing orphaned res.write() calls.

Example:

app.get('/sse', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  const interval = setInterval(() => {
    res.write('data: Update\n\n');
  }, 1000);
 
  // Stop sending updates when client disconnects
  req.on('close', () => {
    clearInterval(interval);
    res.end(); // ✅ Cleanly end the connection
  });
});

4. Validate SSE Headers#

Always set the required SSE headers before calling res.write(). Missing headers can cause Express to default to regular response behavior.

Required Headers for SSE:

res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

5. Debug with Logging#

Add logs to track when headers and responses are sent. Use console.log or tools like morgan to monitor the request lifecycle.

app.get('/sse', (req, res) => {
  console.log('SSE route hit');
  res.setHeader('Content-Type', 'text/event-stream');
  console.log('Headers set');
  res.write('data: Hello\n\n');
  console.log('First message sent');
});

Advanced SSE Tips#

Now that you’ve fixed the error, here are pro tips to enhance your SSE implementation:

1. Send JSON Data#

SSE supports JSON by stringifying objects. Use JSON.stringify() for structured data:

// Server: Send JSON
const data = { time: new Date(), value: Math.random() };
res.write(`data: ${JSON.stringify(data)}\n\n`);
 
// Client: Parse JSON
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('JSON data:', data);
};

2. Custom Event Types#

Use event: to define custom event types (instead of the default message event):

// Server: Send a "status" event
res.write('event: status\n');
res.write('data: Server is online\n\n');
 
// Client: Listen for "status" events
eventSource.addEventListener('status', (event) => {
  console.log('Status update:', event.data);
});

3. Reconnection Handling#

EventSource automatically reconnects on failure, but you can set a retry interval with retry::

// Server: Suggest a 5-second retry interval
res.write('retry: 5000\n'); // 5000ms = 5 seconds
res.write('data: Connection lost. Reconnecting...\n\n');

4. Rate Limiting#

Prevent abuse by limiting SSE connections per client (use libraries like express-rate-limit).

Conclusion#

Server-Sent Events (SSE) are a powerful tool for real-time communication in Express.js, but they require careful handling of HTTP headers and responses. The “Can’t set headers after they are sent” error is common but avoidable by:

  • Using res.write() instead of res.send()/res.json() in SSE routes.
  • Ensuring middleware doesn’t send responses before SSE headers.
  • Handling client disconnections with req.on('close').
  • Validating SSE headers (e.g., text/event-stream).

With these best practices, you can build robust, real-time applications with SSE in Express.js.

References#