Server-Sent Events: The WebSocket Alternative Nobody Uses

TL;DR

SSE is simpler than WebSockets for one-way server-to-client updates. Built into browsers, works over HTTP, automatic reconnection. Use WebSockets only when you need bidirectional communication.

I spent two weeks building a real-time notification system with WebSockets. Custom protocol, connection management, heartbeat logic, reconnection handling, load balancing nightmares. 400 lines of code just to send notifications from server to client.

Then I discovered Server-Sent Events and rebuilt the whole thing in 45 lines. Same functionality, fraction of the complexity, more reliable.

Here's why almost nobody uses SSE, and why you probably should.

The Problem With WebSockets

WebSockets are powerful but bring complexity most apps don't need. Here's what a basic WebSocket server looks like:

// Node.js WebSocket server
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const clients = new Map();

wss.on('connection', (ws, req) => {
    const userId = getUserIdFromRequest(req);
    clients.set(userId, ws);

    // Heartbeat to detect dead connections
    ws.isAlive = true;
    ws.on('pong', () => { ws.isAlive = true; });

    ws.on('message', (message) => {
        const data = JSON.parse(message);

        // Handle different message types
        switch(data.type) {
            case 'subscribe':
                // Subscribe logic
                break;
            case 'unsubscribe':
                // Unsubscribe logic
                break;
            case 'ping':
                ws.send(JSON.stringify({ type: 'pong' }));
                break;
        }
    });

    ws.on('close', () => {
        clients.delete(userId);
    });

    ws.on('error', (error) => {
        console.error('WebSocket error:', error);
        clients.delete(userId);
    });
});

// Heartbeat interval to detect dead connections
const interval = setInterval(() => {
    wss.clients.forEach((ws) => {
        if (!ws.isAlive) {
            return ws.terminate();
        }
        ws.isAlive = false;
        ws.ping();
    });
}, 30000);

// Send notification to user
function sendNotification(userId, notification) {
    const client = clients.get(userId);
    if (client && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({
            type: 'notification',
            data: notification
        }));
    }
}

Client side isn't much better:

// Client WebSocket handling
class NotificationClient {
    constructor(url) {
        this.url = url;
        this.ws = null;
        this.reconnectAttempts = 0;
        this.maxReconnectAttempts = 10;
        this.reconnectDelay = 1000;
        this.connect();
    }

    connect() {
        this.ws = new WebSocket(this.url);

        this.ws.onopen = () => {
            console.log('Connected');
            this.reconnectAttempts = 0;
            this.reconnectDelay = 1000;

            // Subscribe to notifications
            this.send({ type: 'subscribe', channel: 'notifications' });

            // Start heartbeat
            this.startHeartbeat();
        };

        this.ws.onmessage = (event) => {
            const data = JSON.parse(event.data);

            switch(data.type) {
                case 'notification':
                    this.handleNotification(data.data);
                    break;
                case 'pong':
                    // Heartbeat response
                    break;
            }
        };

        this.ws.onclose = () => {
            console.log('Disconnected');
            this.stopHeartbeat();
            this.reconnect();
        };

        this.ws.onerror = (error) => {
            console.error('WebSocket error:', error);
        };
    }

    reconnect() {
        if (this.reconnectAttempts >= this.maxReconnectAttempts) {
            console.error('Max reconnection attempts reached');
            return;
        }

        this.reconnectAttempts++;
        const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);

        setTimeout(() => {
            console.log(`Reconnecting (attempt ${this.reconnectAttempts})...`);
            this.connect();
        }, delay);
    }

    startHeartbeat() {
        this.heartbeatInterval = setInterval(() => {
            if (this.ws.readyState === WebSocket.OPEN) {
                this.send({ type: 'ping' });
            }
        }, 30000);
    }

    stopHeartbeat() {
        if (this.heartbeatInterval) {
            clearInterval(this.heartbeatInterval);
        }
    }

    send(data) {
        if (this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(data));
        }
    }

    handleNotification(notification) {
        console.log('Notification:', notification);
        // Display notification to user
    }
}

const client = new NotificationClient('ws://localhost:8080');

That's a lot of code just to push notifications from server to client. And we haven't even handled:

  • Load balancing (sticky sessions required)
  • Authentication
  • Message queuing
  • Deployment complexity

The Server-Sent Events Alternative

Here's the same notification system with SSE:

// Server (Express)
const express = require('express');
const app = express();

const clients = new Map();

app.get('/notifications', (req, res) => {
    const userId = req.user.id;

    // Set SSE headers
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Store client connection
    clients.set(userId, res);

    // Remove client on disconnect
    req.on('close', () => {
        clients.delete(userId);
    });
});

// Send notification to user
function sendNotification(userId, notification) {
    const client = clients.get(userId);
    if (client) {
        client.write(`data: ${JSON.stringify(notification)}\n\n`);
    }
}

app.listen(3000);

Client side:

// That's it. Built into the browser.
const eventSource = new EventSource('/notifications');

eventSource.onmessage = (event) => {
    const notification = JSON.parse(event.data);
    console.log('Notification:', notification);
    // Display notification
};

eventSource.onerror = (error) => {
    console.error('SSE error:', error);
    // Browser automatically reconnects
};

From 200+ lines to about 30. No protocol implementation, no reconnection logic, no heartbeats. Just works.

What SSE Actually Is

Server-Sent Events is an HTTP-based protocol for server-to-client streaming. The server keeps the connection open and sends events whenever it wants:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: {"message": "Hello"}

data: {"message": "World"}

event: notification
data: {"type": "info", "text": "New message"}

The browser handles everything:

  • Parsing the stream
  • Automatic reconnection with exponential backoff
  • Last-Event-ID for resuming after disconnect
  • Built-in event handling

SSE vs WebSockets: When to Use Each

Use SSE When:

One-way communication (server → client):

// Live notifications
eventSource.onmessage = (e) => {
    showNotification(JSON.parse(e.data));
};

// Live sports scores
eventSource.onmessage = (e) => {
    updateScore(JSON.parse(e.data));
};

// Stock price updates
eventSource.onmessage = (e) => {
    updatePrice(JSON.parse(e.data));
};

// Server logs streaming
eventSource.onmessage = (e) => {
    appendLog(JSON.parse(e.data));
};

Works over standard HTTP:

  • No special proxy configuration
  • Works with existing load balancers
  • Standard HTTP authentication
  • Standard CORS

Automatic reconnection matters:

  • Mobile connections that drop frequently
  • Users closing laptops
  • Network switches

Use WebSockets When:

Bidirectional communication needed:

// Chat application (client sends messages too)
ws.send(JSON.stringify({ message: 'Hello' }));

// Collaborative editing (client sends edits)
ws.send(JSON.stringify({
    op: 'insert',
    position: 42,
    text: 'foo'
}));

// Online games (constant two-way data)
ws.send(JSON.stringify({
    action: 'move',
    x: 100,
    y: 200
}));

Low latency is critical:

  • Sub-100ms round-trip needed
  • Real-time gaming
  • Trading systems

Binary data:

  • Video streaming
  • File transfers
  • Protocol buffers

Real-World Examples

Live Activity Feed

// Server
app.get('/feed', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Send current state
    res.write(`data: ${JSON.stringify(getCurrentFeed())}\n\n`);

    // Subscribe to updates
    const subscription = subscribeFeedUpdates((update) => {
        res.write(`data: ${JSON.stringify(update)}\n\n`);
    });

    req.on('close', () => {
        subscription.unsubscribe();
    });
});

// Client
const feed = new EventSource('/feed');
feed.onmessage = (e) => {
    const update = JSON.parse(e.data);
    prependToFeed(update);
};

Progress Tracking

// Server - long-running job
app.post('/export', async (req, res) => {
    const jobId = createExportJob();
    res.json({ jobId });
});

app.get('/export/:jobId/progress', (req, res) => {
    const { jobId } = req.params;

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    const interval = setInterval(async () => {
        const progress = await getJobProgress(jobId);
        res.write(`data: ${JSON.stringify(progress)}\n\n`);

        if (progress.complete) {
            clearInterval(interval);
            res.write('event: complete\n');
            res.write(`data: ${JSON.stringify(progress.result)}\n\n`);
            res.end();
        }
    }, 1000);

    req.on('close', () => {
        clearInterval(interval);
    });
});

// Client
function trackExport(jobId) {
    const progress = new EventSource(`/export/${jobId}/progress`);

    progress.onmessage = (e) => {
        const data = JSON.parse(e.data);
        updateProgressBar(data.percent);
    };

    progress.addEventListener('complete', (e) => {
        const result = JSON.parse(e.data);
        showResult(result);
        progress.close();
    });
}

Server Monitoring Dashboard

// Server
app.get('/metrics', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    const sendMetrics = () => {
        const metrics = {
            cpu: os.loadavg()[0],
            memory: process.memoryUsage(),
            connections: server.connections,
            timestamp: Date.now()
        };
        res.write(`data: ${JSON.stringify(metrics)}\n\n`);
    };

    // Send immediately
    sendMetrics();

    // Then every 2 seconds
    const interval = setInterval(sendMetrics, 2000);

    req.on('close', () => {
        clearInterval(interval);
    });
});

// Client
const metrics = new EventSource('/metrics');
metrics.onmessage = (e) => {
    const data = JSON.parse(e.data);
    updateDashboard(data);
};

Advanced SSE Features

Named Events

// Server - send different event types
res.write('event: notification\n');
res.write(`data: ${JSON.stringify({ text: 'New message' })}\n\n`);

res.write('event: alert\n');
res.write(`data: ${JSON.stringify({ level: 'error' })}\n\n`);

// Client - handle different events
const stream = new EventSource('/events');

stream.addEventListener('notification', (e) => {
    showNotification(JSON.parse(e.data));
});

stream.addEventListener('alert', (e) => {
    showAlert(JSON.parse(e.data));
});

Resume After Disconnect

// Server - include event ID
let eventId = 0;

function sendEvent(res, data) {
    eventId++;
    res.write(`id: ${eventId}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
}

app.get('/events', (req, res) => {
    // Client sends last received ID
    const lastEventId = req.headers['last-event-id'];

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Send missed events
    if (lastEventId) {
        const missedEvents = getEventsSince(lastEventId);
        missedEvents.forEach(event => sendEvent(res, event));
    }

    // Continue with live events
    subscribeToEvents((event) => {
        sendEvent(res, event);
    });
});

// Client - automatic, just works
const stream = new EventSource('/events');
// Browser automatically sends Last-Event-ID header on reconnect

Custom Retry Timing

// Server - tell client when to retry
res.write('retry: 5000\n');  // Retry after 5 seconds
res.write('data: Server busy\n\n');

Production Considerations

Handling Many Connections

// Keep track of connections
const connections = new Set();

app.get('/events', (req, res) => {
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    connections.add(res);

    req.on('close', () => {
        connections.delete(res);
    });
});

// Broadcast to all
function broadcast(event) {
    connections.forEach((res) => {
        try {
            res.write(`data: ${JSON.stringify(event)}\n\n`);
        } catch (error) {
            connections.delete(res);
        }
    });
}

// Limit max connections
app.use((req, res, next) => {
    if (connections.size >= 10000) {
        return res.status(503).send('Too many connections');
    }
    next();
});

Authentication

// Server
app.get('/events', authenticateToken, (req, res) => {
    const userId = req.user.id;

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Subscribe user to their events
    subscribeUserEvents(userId, (event) => {
        res.write(`data: ${JSON.stringify(event)}\n\n`);
    });
});

// Client - pass token via query param or use EventSource polyfill
// Built-in EventSource doesn't support custom headers
const stream = new EventSource('/events?token=' + authToken);

// Or use fetch polyfill for headers
import { EventSourcePolyfill } from 'event-source-polyfill';

const stream = new EventSourcePolyfill('/events', {
    headers: {
        'Authorization': `Bearer ${token}`
    }
});

Nginx Configuration

# Disable buffering for SSE
location /events {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding off;
}

Load Balancing

Unlike WebSockets, SSE works with standard HTTP load balancing:

# No sticky sessions needed
upstream backend {
    server backend1:3000;
    server backend2:3000;
    server backend3:3000;
}

# Connection will stay on one server naturally
# If server dies, client reconnects to another

Redis for Multi-Server Broadcasting

// Server
const Redis = require('ioredis');
const pub = new Redis();
const sub = new Redis();

// Subscribe to Redis channel
sub.subscribe('events');
sub.on('message', (channel, message) => {
    const event = JSON.parse(message);

    // Broadcast to connected clients on this server
    broadcastToClients(event);
});

// Publish event from any server
function publishEvent(event) {
    pub.publish('events', JSON.stringify(event));
}

// Other servers receive and broadcast to their clients

Browser Support and Polyfills

SSE is supported in all modern browsers except... Internet Explorer. But there's a polyfill:

// Use polyfill for old browsers
import { EventSourcePolyfill } from 'event-source-polyfill';

const EventSource = window.EventSource || EventSourcePolyfill;
const stream = new EventSource('/events');

Performance Comparison

I benchmarked both approaches for a notification system:

SSE:

  • Connection overhead: ~1KB (HTTP headers)
  • Message overhead: ~20 bytes per event
  • Server memory: ~5KB per connection
  • 10,000 concurrent connections: ~50MB RAM

WebSocket:

  • Connection overhead: ~2KB (handshake + upgrade)
  • Message overhead: ~2-10 bytes per frame (depending on masking)
  • Server memory: ~8KB per connection
  • 10,000 concurrent connections: ~80MB RAM

For server→client only, SSE is actually lighter weight.

The Hybrid Approach

Sometimes you need both:

// SSE for server → client (notifications, updates)
const notifications = new EventSource('/notifications');
notifications.onmessage = (e) => {
    showNotification(JSON.parse(e.data));
};

// Regular HTTP POST for client → server (actions)
async function markAsRead(notificationId) {
    await fetch('/notifications/' + notificationId, {
        method: 'POST',
        body: JSON.stringify({ read: true })
    });
}

This is simpler than WebSockets and works for most "real-time" features.

When I Actually Use Each

SSE (90% of cases):

  • Notifications
  • Live feeds
  • Dashboard updates
  • Progress tracking
  • Server logs
  • Status monitoring
  • Live search results

WebSockets (10% of cases):

  • Chat applications
  • Collaborative editing
  • Online games
  • Video calls (signaling)
  • Trading platforms

Regular HTTP (still the default):

  • Everything else

The Real Advantage: Simplicity

The killer feature of SSE isn't performance or features—it's simplicity.

No custom protocol. It's just HTTP with chunked encoding.

No connection state management. The browser handles reconnection.

No special proxy config. Works with standard reverse proxies.

No client library needed. Built into every browser.

No deployment complexity. Deploy like any HTTP endpoint.

This means:

  • Faster development
  • Easier debugging
  • Fewer moving parts
  • Lower operational complexity

My Advice

Default to SSE for server→client updates. It's simpler and works for 90% of real-time features.

Use WebSockets only when you need:

  • Bidirectional communication
  • Sub-100ms latency
  • Binary protocols

Test your assumptions. Most "real-time" features work fine with SSE + regular HTTP POST.

Keep it simple. The best protocol is the one that solves your problem with the least complexity.

I've replaced WebSockets with SSE in three production apps. Development is faster, debugging is easier, and deployment is simpler. Users can't tell the difference.

Sometimes the boring technology is the right technology.