You Might Not Need WebSockets: Server-Sent Events for Real-Time Frontend Updates

In this tutorial, I'll show you how to implement Server-Sent Events (SSE) in your frontend application. SSE is a simple and effective way to receive real-time updates from your server without the overhead of WebSockets or the inefficiency of polling.

What are Server-Sent Events?

Server-Sent Events are a standard that allows a web page to get updates from a server. Unlike WebSockets, SSE is a one-way communication channel - the server sends data to the client, but not vice versa. This makes SSE perfect for scenarios like:

  • Live feeds and notifications
  • Real-time dashboards
  • Status updates
  • Chat applications (for receiving messages)
  • Stock tickers

SSE has several advantages over alternatives:

  • Simpler than WebSockets - easier to implement on both client and server
  • Automatic reconnection - built into the protocol
  • Native browser support - no additional libraries needed
  • Regular HTTP - works with standard proxies and firewalls
  • Text-based - easy to debug

Browser Support

SSE is supported in all modern browsers including Chrome, Firefox, Safari, and Edge.

Basic Implementation

Let's start with a simple SSE client implementation:

// Create an EventSource instance pointing to the SSE endpoint
const eventSource = new EventSource('/api/events');

// Listen for messages from the server
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received data:', data);
  // Update your UI with the received data
};

// Handle connection open
eventSource.onopen = () => {
  console.log('SSE connection established');
};

// Handle errors
eventSource.onerror = (error) => {
  console.error('SSE connection error:', error);
  // Automatic reconnection will happen by default
};

// To close the connection when no longer needed
function closeConnection() {
  eventSource.close();
}

Handling Different Event Types

One powerful feature of SSE is the ability to send different types of events over the same connection:

const eventSource = new EventSource('/api/events');

// Listen for specific event types
eventSource.addEventListener('update', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received update:', data);
  // Handle updates
});

eventSource.addEventListener('notification', (event) => {
  const data = JSON.parse(event.data);
  console.log('Received notification:', data);
  // Handle notifications
});

// The onmessage handler only catches events without an event type
eventSource.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Received default message:', data);
};

Adding Authentication

For authenticated endpoints, you'll need to include credentials:

// With credentials for authenticated endpoints
const eventSource = new EventSource('/api/events', { withCredentials: true });

If you need to send a token, you might need to customize the request further. Since EventSource doesn't support custom headers directly, you can pass the token in the URL:

const token = 'your-auth-token';
const eventSource = new EventSource(`/api/events?token=${token}`);

Handling Reconnection Explicitly

While SSE has automatic reconnection, sometimes you want more control:

let eventSource;
let retryCount = 0;
const maxRetries = 5;

eventSource = new EventSource('/api/events');

eventSource.onopen = () => {
  console.log('Connection established');
  retryCount = 0; // Reset retry count on successful connection
};

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

function handleError(error) {
  console.error('SSE error:', error);
  
  // Close current connection
  eventSource.close();
  
  // Try to reconnect with exponential backoff
  if (retryCount < maxRetries) {
    const timeout = Math.pow(2, retryCount) * 1000;
    retryCount++;
    console.log(`Reconnecting in ${timeout}ms...`);
    
    setTimeout(() => {
      eventSource = new EventSource('/api/events');
      
      // Re-attach event listeners for the new connection
      eventSource.onopen = () => {
        console.log('Connection established');
        retryCount = 0;
      };
      
      eventSource.onmessage = (event) => {
        const data = JSON.parse(event.data);
        console.log('Received:', data);
      };
      
      eventSource.onerror = handleError;
    }, timeout);
  } else {
    console.error('Max retries reached. Giving up.');
    // Show an error to the user
  }
}

eventSource.onerror = handleError;

Using with Modern Frameworks

React

import { useState, useEffect } from 'react';

function EventComponent() {
  const [events, setEvents] = useState([]);
  
  useEffect(() => {
    const eventSource = new EventSource('/api/events');
    
    eventSource.onmessage = (event) => {
      const newEvent = JSON.parse(event.data);
      setEvents(prevEvents => [newEvent, ...prevEvents].slice(0, 10));
    };
    
    // Cleanup function
    return () => {
      eventSource.close();
    };
  }, []); // Empty dependency array means this runs once on mount
  
  return (
    <div>
      <h2>Latest Events</h2>
      <ul>
        {events.map((event, index) => (
          <li key={index}>{event.message}</li>
        ))}
      </ul>
    </div>
  );
}

Vue 3

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const events = ref([]);
let eventSource = null;

onMounted(() => {
  eventSource = new EventSource('/api/events');
  
  eventSource.onmessage = (event) => {
    const newEvent = JSON.parse(event.data);
    events.value = [newEvent, ...events.value].slice(0, 10);
  };
});

onUnmounted(() => {
  if (eventSource) {
    eventSource.close();
  }
});
</script>

<template>
  <div>
    <h2>Latest Events</h2>
    <ul>
      <li v-for="(event, index) in events" :key="index">
        {{ event.message }}
      </li>
    </ul>
  </div>
</template>

Common Pitfalls and Solutions

1. Maximum Connections Limit

Browsers limit the number of concurrent SSE connections to the same domain (typically 6). Solutions:

  • Use a single SSE connection and multiplex different events over it
  • Use domain sharding (different subdomains) for multiple connections
  • Consider WebSockets if you need many concurrent connections

2. Memory Leaks

SSE connections can cause memory leaks if not properly closed:

// Always close the connection when the component is unmounted
function cleanupSSE() {
  if (eventSource) {
    eventSource.close();
    eventSource = null;
  }
}

3. Long Idle Connections

Some proxies or load balancers might close idle connections. Server should send keepalive messages:

// On the client, prepare to handle keep-alive events
eventSource.addEventListener('keepalive', (event) => {
  // Just ignore these events, they're just to keep the connection open
  console.log('Received keepalive');
});

Performance Considerations

To optimize SSE performance:

  1. Keep messages small - Send only what's needed
  2. Batch updates - For high-frequency updates, batch them on the server
  3. Use compression - Enable gzip for SSE endpoints
  4. Implement backoff for reconnections - As shown in the reconnection example
  5. Set appropriate cache headers - To prevent caching of the event stream:
Cache-Control: no-cache
Connection: keep-alive

Conclusion

Server-Sent Events provide a straightforward way to implement real-time updates in web applications. They're perfect for many use cases where you need server-to-client communication without the complexity of WebSockets.

By understanding the patterns and best practices shown in this tutorial, you can create robust applications that respond instantly to backend changes, providing a more dynamic and engaging user experience.

If you have any questions or run into issues implementing SSE in your frontend application, leave a comment below and I'll do my best to help!