Testing Server-Sent Events: SSE, EventSource, and Reconnection

Testing Server-Sent Events: SSE, EventSource, and Reconnection

Server-Sent Events get overlooked. Teams reach for WebSockets by default, then discover they've built a complex bidirectional system for a problem that only required one-way server push. SSE is simpler, uses plain HTTP, works through proxies without configuration, and reconnects automatically. For a large class of real-time features — notifications, live dashboards, feed updates — SSE is the better tool.

But SSE testing is even less documented than WebSocket testing. This guide covers the full picture: understanding the protocol, testing with curl, writing SSE tests in JavaScript, and load testing.

SSE vs WebSocket: When to Use Each

Before testing SSE, understand when you should be using it versus WebSocket.

Use SSE when:

  • Communication is one-directional: server pushes to client
  • You need automatic reconnection without client-side logic
  • You want to work through existing HTTP infrastructure without firewall changes
  • Your clients are browsers (EventSource is native, no library needed)
  • You're building: live feeds, notifications, progress updates, streaming AI responses

Use WebSocket when:

  • Clients need to send data frequently (chat, gaming, collaborative editing)
  • You need low-latency bidirectional communication
  • You're dealing with non-browser clients that need binary data

The common mistake: using WebSockets for a notification system where the client only receives updates. SSE handles this with less code and fewer operational headaches.

The Protocol

SSE is plain HTTP. The server sends a response with Content-Type: text/event-stream and keeps the connection open, streaming data in a specific text format:

id: 1
event: price-update
data: {"symbol":"AAPL","price":150.23}

id: 2
event: price-update
data: {"symbol":"MSFT","price":320.10}

Each event is a block of key-value lines followed by a blank line. Fields:

  • data: — The payload (required). Multiple data: lines are concatenated with newlines.
  • event: — Event type (optional, defaults to "message")
  • id: — Event ID used for reconnection (optional but important)
  • retry: — Reconnection delay in milliseconds (optional)

The browser's EventSource automatically reconnects and sends the Last-Event-ID header with the last received ID, allowing the server to resume from where it left off.

Testing with curl

curl is your first debugging tool. A plain GET to an SSE endpoint should stream events:

# Basic SSE connection
curl -N -H "Accept: text/event-stream" https://api.example.com/events

# With auth header
curl -N -H "Accept: text/event-stream" \
     -H "Authorization: Bearer your-token" \
     https://api.example.com/events

# Check the response headers (separate from body)
curl -N -I -H "Accept: text/event-stream" https://api.example.com/events

# Verify reconnection header is sent
curl -N -H "Accept: text/event-stream" \
     -H "Last-Event-ID: 42" \
     https://api.example.com/events

The -N flag disables output buffering, so you see events as they arrive rather than waiting for the connection to close (which it never will for a healthy SSE endpoint).

What to check manually:

  • Content-Type: text/event-stream header is present
  • Cache-Control: no-cache is set (prevents proxy caching)
  • Connection: keep-alive is set
  • Events flow in the expected format
  • The server correctly handles Last-Event-ID for resumption

Writing SSE Tests in JavaScript

For Node.js, the eventsource package provides an EventSource implementation:

npm install eventsource

Basic test structure:

const EventSource = require('eventsource');

describe('SSE endpoint', () => {
  test('streams events after connection', (done) => {
    const es = new EventSource('http://localhost:3000/events', {
      headers: { Authorization: 'Bearer test-token' },
    });
    
    const received = [];
    
    es.addEventListener('price-update', (event) => {
      received.push(JSON.parse(event.data));
      
      if (received.length >= 3) {
        es.close();
        
        expect(received[0].symbol).toBeDefined();
        expect(received[0].price).toBeGreaterThan(0);
        done();
      }
    });
    
    es.onerror = (err) => {
      es.close();
      done(new Error(`SSE error: ${JSON.stringify(err)}`));
    };
    
  }, 15000);
});

For async/await patterns, wrap EventSource in a Promise:

function collectSSEEvents(url, eventType, count, options = {}) {
  return new Promise((resolve, reject) => {
    const es = new EventSource(url, options);
    const events = [];
    
    const timeout = setTimeout(() => {
      es.close();
      reject(new Error(`Timeout: collected ${events.length}/${count} events`));
    }, options.timeout || 10000);
    
    es.addEventListener(eventType, (event) => {
      events.push(JSON.parse(event.data));
      if (events.length >= count) {
        clearTimeout(timeout);
        es.close();
        resolve(events);
      }
    });
    
    es.onerror = (err) => {
      clearTimeout(timeout);
      es.close();
      reject(err);
    };
  });
}

// Usage
test('receives 5 price updates', async () => {
  const events = await collectSSEEvents(
    'http://localhost:3000/events',
    'price-update',
    5,
    { headers: { Authorization: 'Bearer test-token' } }
  );
  
  expect(events).toHaveLength(5);
  events.forEach(event => {
    expect(event.symbol).toMatch(/^[A-Z]+$/);
    expect(event.price).toBeGreaterThan(0);
    expect(event.timestamp).toBeDefined();
  });
});

Testing Event Types and Data Formats

If your SSE endpoint emits multiple event types, test each one explicitly:

test('emits correct event types', async () => {
  const es = new EventSource('http://localhost:3000/events', {
    headers: { Authorization: 'Bearer test-token' },
  });
  
  const eventTypes = new Set();
  
  await new Promise((resolve) => {
    es.onmessage = (event) => {
      eventTypes.add(event.type);
      if (eventTypes.size >= 3) {
        es.close();
        resolve();
      }
    };
    
    // Listen for all named event types
    ['price-update', 'trade', 'orderbook-update'].forEach(type => {
      es.addEventListener(type, (event) => {
        eventTypes.add(type);
        if (eventTypes.size >= 3) {
          es.close();
          resolve();
        }
      });
    });
  });
  
  expect(eventTypes.has('price-update')).toBe(true);
});

Test that event IDs are present and sequential (or at least monotonically increasing):

test('events have sequential IDs', async () => {
  const events = await collectSSEEventsWithMetadata(
    'http://localhost:3000/events',
    10
  );
  
  const ids = events.map(e => parseInt(e.lastEventId));
  
  // Each ID should be greater than the previous
  for (let i = 1; i < ids.length; i++) {
    expect(ids[i]).toBeGreaterThan(ids[i - 1]);
  }
});

Testing Reconnection Behavior

Reconnection is SSE's killer feature. The browser EventSource handles it automatically. Test that your server supports it correctly:

test('resumes from last event ID after reconnect', async () => {
  // First connection — collect 5 events and note the last ID
  let lastEventId;
  const firstBatch = await new Promise((resolve, reject) => {
    const es = new EventSource('http://localhost:3000/events', {
      headers: { Authorization: 'Bearer test-token' },
    });
    
    const events = [];
    es.onmessage = (event) => {
      events.push(event);
      lastEventId = event.lastEventId;
      if (events.length >= 5) {
        es.close();
        resolve(events);
      }
    };
  });
  
  // Second connection — provide Last-Event-ID
  const secondBatch = await collectSSEEvents(
    'http://localhost:3000/events',
    'message',
    5,
    {
      headers: {
        Authorization: 'Bearer test-token',
        'Last-Event-ID': lastEventId,
      },
    }
  );
  
  // Second batch should start after the last event from first batch
  const firstBatchLastId = parseInt(lastEventId);
  const secondBatchFirstId = parseInt(secondBatch[0].id);
  
  expect(secondBatchFirstId).toBeGreaterThan(firstBatchLastId);
});

Test that the server handles Last-Event-ID: 0 (first connection) and missing Last-Event-ID (also first connection) identically:

test('handles missing Last-Event-ID gracefully', async () => {
  const events = await collectSSEEvents(
    'http://localhost:3000/events',
    'message',
    3
  );
  expect(events).toHaveLength(3);
});

Auth Testing

SSE auth has the same challenge as WebSocket auth: the browser's EventSource doesn't support custom headers. You have a few options:

Token in query string (common, not ideal):

GET /events?token=abc123

Test that missing or invalid tokens get rejected:

test('rejects connection without token', async () => {
  await expect(
    collectSSEEvents('http://localhost:3000/events', 'message', 1, {
      timeout: 3000,
    })
  ).rejects.toThrow();
});

test('rejects connection with invalid token', async () => {
  await expect(
    collectSSEEvents('http://localhost:3000/events?token=invalid', 'message', 1, {
      timeout: 3000,
    })
  ).rejects.toThrow();
});

Cookie-based auth (works natively with EventSource): The browser includes cookies automatically. In tests, set the cookie on the EventSource options:

const es = new EventSource('/events', {
  withCredentials: true, // Include cookies
});

For Node.js test clients, pass the cookie as a header:

const es = new EventSource('http://localhost:3000/events', {
  headers: { Cookie: 'session=your-session-token' },
});

Custom header auth (requires a proxy or non-browser EventSource): In tests, use the eventsource package's header support. In production browsers, you'll need to proxy through a service worker or use a polling fallback.

Load Testing SSE

SSE is simpler to load test than WebSockets — it's just HTTP. k6 handles SSE well:

import http from 'k6/http';
import { check } from 'k6';

export const options = {
  vus: 200,
  duration: '2m',
  thresholds: {
    http_req_duration: ['p95<500'],
    http_req_failed: ['rate<0.01'],
  },
};

export default function () {
  const response = http.get('https://api.example.com/events', {
    headers: {
      Accept: 'text/event-stream',
      Authorization: 'Bearer test-token',
    },
    timeout: '30s',
  });
  
  check(response, {
    'status 200': (r) => r.status === 200,
    'content type is event stream': (r) => 
      r.headers['Content-Type'].includes('text/event-stream'),
    'received data': (r) => r.body.length > 0,
  });
}

For a more realistic SSE load test that maintains persistent connections, you'll need a lower-level approach or a dedicated tool. The key metrics to track for SSE load:

  • Time to first event — How long after connecting does the first event arrive?
  • Event throughput — Events per second across all connections
  • Connection keep-alive duration — Are connections being dropped prematurely?
  • Server memory per connection — SSE connections are cheaper than WS but still consume resources

Common SSE Bugs to Test For

Missing Content-Type header. The browser EventSource silently fails if the Content-Type isn't text/event-stream. Always test that the header is present and correct.

Buffering proxies. Nginx and other proxies buffer responses by default. An SSE endpoint behind a misconfigured proxy appears to work (HTTP 200, correct headers) but events never arrive — they're buffered until the connection closes. Test by checking that events arrive within a few seconds of being sent, not in a delayed batch.

No event IDs. Without IDs, reconnection sends no Last-Event-ID, and the server can't resume. All events since the disconnect are lost. Test reconnection explicitly to catch this.

Heartbeat absence. Many proxies close idle connections after 30-60 seconds. If your server doesn't send periodic heartbeats, connections silently drop. Test connection survival over several minutes:

test('connection stays alive for 3 minutes', async () => {
  const es = new EventSource('http://localhost:3000/events', {
    headers: { Authorization: 'Bearer test-token' },
  });
  
  let connectionAlive = true;
  es.onerror = () => { connectionAlive = false; };
  
  await new Promise(resolve => setTimeout(resolve, 180000));
  
  expect(connectionAlive).toBe(true);
  es.close();
}, 200000); // Extended timeout

SSE is underused in part because engineers assume "we'll need bidirectional later so let's just use WebSockets." Often they don't need bidirectional. If your use case is server push — notifications, live data, streaming responses — SSE is simpler to build, simpler to test, and simpler to operate. Test it properly and it'll serve you well.

For teams building real-time features and looking for an end-to-end testing layer beyond unit tests — HelpMeTest supports testing SSE and WebSocket behavior as part of automated browser-based test flows, so you can catch integration failures before they reach users.

Start now free