hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Real-Time APIs with @hectoday/http

Beyond Request-Response

  • Why Real-Time Matters
  • Project Setup

Polling

  • Short Polling
  • Long Polling

Server-Sent Events

  • How SSE Works
  • Building an SSE Endpoint
  • Event Types and IDs
  • SSE in Practice

WebSockets

  • How WebSockets Work
  • Building a WebSocket Server
  • Rooms and Broadcasting
  • Authentication on WebSockets
  • Handling Disconnects and Reconnection

Patterns and Architecture

  • Pub/Sub
  • Presence
  • Scaling Real-Time

Putting It All Together

  • Choosing the Right Approach
  • Capstone: Live Task Board

Event Types and IDs

Named events

SSE supports named event types with the event: field. The client listens for specific types:

event: task_created
data: {"task":{"id":"task-5","title":"New task"}}

event: task_moved
data: {"task":{"id":"task-3","listId":"list-done"}}

event: task_deleted
data: {"taskId":"task-2"}

On the client, use addEventListener instead of onmessage to listen for specific types:

const source = new EventSource("/boards/board-1/events");

source.addEventListener("task_created", (event) => {
  const data = JSON.parse(event.data);
  addTaskToBoard(data.task);
});

source.addEventListener("task_moved", (event) => {
  const data = JSON.parse(event.data);
  moveTaskOnBoard(data.task);
});

source.addEventListener("task_deleted", (event) => {
  const data = JSON.parse(event.data);
  removeTaskFromBoard(data.taskId);
});

onmessage only fires for events without a named type. addEventListener fires for the specific type. This lets the client handle each event type differently without parsing the data first.

Update the formatting

function formatSSE(data: any, eventType?: string, id?: string): string {
  let event = "";
  if (id) event += `id: ${id}\n`;
  if (eventType) event += `event: ${eventType}\n`;
  event += `data: ${JSON.stringify(data)}\n\n`;
  return event;
}

// Usage
broadcast(boardId, { task }, "task_created", eventId);

Event IDs and resumption

Each event can have an id: field. The browser tracks the last received ID. When the connection drops and the browser reconnects, it sends a Last-Event-ID header with the ID of the last event it received.

The server can use this to replay missed events:

id: 42
event: task_created
data: {"task":{"id":"task-5"}}

id: 43
event: task_moved
data: {"task":{"id":"task-3","listId":"list-done"}}

If the connection drops after event 42, the browser reconnects with Last-Event-ID: 42. The server replays events starting from ID 43.

Implementing resume

Store recent events in a buffer so they can be replayed:

// src/sse.ts — add event buffer
const EVENT_BUFFER_SIZE = 100;
const eventBuffer: Map<string, { id: number; type: string; data: any }[]> = new Map();
let globalEventId = 0;

export function broadcast(boardId: string, data: any, eventType?: string): void {
  const id = ++globalEventId;
  const event = formatSSE(data, eventType, String(id));

  // Store in buffer
  if (!eventBuffer.has(boardId)) eventBuffer.set(boardId, []);
  const buffer = eventBuffer.get(boardId)!;
  buffer.push({ id, type: eventType ?? "message", data });
  if (buffer.length > EVENT_BUFFER_SIZE) buffer.shift();

  // Send to connected clients
  const matching = clients.filter((c) => c.boardId === boardId);
  for (const client of matching) {
    try {
      client.controller.enqueue(event);
    } catch {
      const index = clients.indexOf(client);
      if (index !== -1) clients.splice(index, 1);
    }
  }
}

export function replayEvents(boardId: string, lastEventId: string): string {
  const buffer = eventBuffer.get(boardId) ?? [];
  const lastId = parseInt(lastEventId, 10);
  const missed = buffer.filter((e) => e.id > lastId);
  return missed.map((e) => formatSSE(e.data, e.type, String(e.id))).join("");
}

Update the SSE route to handle Last-Event-ID:

route.get("/boards/:boardId/events", {
  resolve: (c) => {
    const lastEventId = c.request.headers.get("last-event-id");

    const stream = new ReadableStream({
      start(controller) {
        // Replay missed events
        if (lastEventId) {
          const replay = replayEvents(c.params.boardId, lastEventId);
          if (replay) controller.enqueue(replay);
        }

        // Register for future events
        const client = { boardId: c.params.boardId, controller };
        clients.push(client);

        controller.enqueue(formatSSE({ type: "connected" }));
      },
      cancel() {
        // cleanup...
      },
    });

    return new Response(stream, {
      headers: {
        "content-type": "text/event-stream",
        "cache-control": "no-cache",
        connection: "keep-alive",
      },
    });
  },
});

When the browser reconnects, the server replays any events the client missed during the disconnection. The client’s event handlers process them as if they arrived in real time.

Exercises

Exercise 1: Add named event types. Use event: task_created instead of putting the type in the data. Verify addEventListener works on the client.

Exercise 2: Add event IDs. Connect, receive some events, disconnect (kill the connection), reconnect. Verify missed events are replayed.

Exercise 3: Fill the event buffer beyond its limit. Verify old events are dropped and the buffer stays at the configured size.

What happens when a browser reconnects to an SSE endpoint with Last-Event-ID?

← Building an SSE Endpoint SSE in Practice →

© 2026 hectoday. All rights reserved.