Server-Sent Events (SSE) are a useful way to push real-time updates from server to client over a long-lived HTTP connection. In the multiplayer mystery game Whodunit, SSE events keep players synchronized as they investigate clues, make accusations, and reveal the killer.

The game tracks connected players based on their SSE connection status, making proper connection cleanup critical for accurate player counts and game state. Whodunit powers its web app with HTMX, using the sse extension to simplify SSE message reactivity.

Unfortunately, browsers don’t all handle SSE connection closure the same way, especially when users navigate away from the page. This inconsistency causes problems for applications that rely on connection state for internal logic. In Whodunit’s case, the game engine uses player connection to decide if the game should wait for the player’s input. If a player leaves the game and the SSE connection isn’t closed, the remaining players would have to wait for the turn timer on the missing player.

Fortunately, the disconnect issue can be solved with a little client-side JavaScript. Let’s explore how SSE works, and how to make connection closure robust.

If you’re looking for a golang SSE library, check out sse-go.

SSE Basics

SSE works by “upgrading” a standard HTTP connection into a persistent stream. The server sends an initial response with specific headers and keeps the connection open to send events as they occur.

Server Implementation

Here’s how the SSE headers are set up:

func setSSEHeaders(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")

    // Set connection headers for HTTP/1 connections.
    if r.ProtoMajor == 1 {
        w.Header().Set("Connection", "keep-alive")
        w.Header().Set("Keep-Alive", "timeout=60")
    }
}

The key headers are:

  • Content-Type: text/event-stream - Tells the browser this is an SSE stream
  • Cache-Control: no-cache - Prevents caching of the stream
  • Connection: keep-alive and Keep-Alive: timeout=60 - Only for HTTP/1.1 connections. If sent on HTTP/2 connections, Chrome and Firefox ignore them and Safari rejects the response.

Server-Side Connection Tracking

On the server side, Whodunit’s SSE handler connects players when they establish a connection and disconnects them when the connection closes:

func (h Handlers) sseSetup(w http.ResponseWriter, r *http.Request) {
    // ... authentication and setup ...

    lifecycleHooks := sse.LifecycleHooks{
        // OnConnect is called when the connection has upgraded and is ready to receive SSE messages
        OnConnect: func(sub sse.Subscription) {
            h.service.PlayerConnected(player)
        },
    }
    
    // Register for SSE. ServeHTTP returns when the connection closes
    h.eventServer.ServeHTTP(w, r, topics, lifecycleHooks)
    
    // Notify that player has disconnected
    err = h.service.PlayerDisconnected(player)
    // ... error handling ...
}

Client Implementation with HTMX

HTMX lets you use modern browser features — AJAX, CSS transitions, WebSockets, and SSE — directly from HTML attributes. You can build dynamic web applications with declarative HTML, minimizing custom JavaScript for common patterns like navigation in a server-side-rendered web app.

Whodunit uses HTMX’s SSE extension for client-side handling:

<div hx-ext="sse" sse-connect="/event">
  <!-- Content gets updated via SSE events -->
</div>

HTMX automatically creates an EventSource object and handles the connection lifecycle. An SSE event’s payload can be any format. Whodunit sends rendered HTML to display game updates. The events look like this:

event: player-joined
data: <html content>

event: game-started
data: <html content>

The Connection Closure Problem

While SSE connections work reliably for sending events, browser inconsistencies appear when users leave the page.

Works: Page Reload

Refreshing the page consistently closes SSE connections in Chrome, Firefox, and Safari. The browser terminates all connections before loading the new page.

Doesn’t Work: Navigating Away

Here’s where things get tricky. When users navigate away from the page (clicking links, using the back button, or typing a new URL), browser behavior varies:

  • Firefox: Immediately closes SSE connections
  • Safari: Immediately closes SSE connections
  • Chrome: Holds connections open for 60 seconds before closing them

Chrome’s delayed closure is a problem for Whodunit. Players who leave would still appear as “connected” for up to a minute, interfering with the game’s state and making the game seem unreliable.

The Fix: beforeunload Event

The fix is to explicitly close SSE connections when the page is about to unload.

  1. Track EventSource objects: When HTMX creates an SSE connection, the EventSource is stored in an array.
  2. Handle beforeunload: When the page is about to unload (navigation, refresh, or close), tracked connections are explicitly closed.
  3. Consistent behavior across browsers: Closing connections in beforeunload keeps behavior consistent regardless of how the browser handles connections on navigation.

Here’s Whodunit’s implementation:

// Store event sources so they can be closed when the page is unloaded
var eventSources = [];

// After HTMX loads, listen and store SSE Open events
htmx.onLoad(function() {
    document.body.addEventListener('htmx:sseOpen', function (e) {
        for (var eventSource of eventSources) {
            if (eventSource === e.detail.source) {
                return;
            }
        }

        eventSources.push(e.detail.source);
    });
});

// When the browser unloads, close all event sources
window.addEventListener('beforeunload', function(event) {
    for (var eventSource of eventSources) {
        eventSource.close();
    }
});

Takeaways

  1. Browsers are inconsistent: Chrome’s 60-second connection hold can cause problems for applications that rely on connection state.
  2. Explicit closure is necessary: Close SSE connections in the beforeunload event for consistent behavior.
  3. Track connections on both sides: Client-side tracking ensures proper cleanup, server-side tracking maintains accurate state.
  4. Test across browsers: What works in one browser may not work in another.

For applications like Whodunit where player connection management is critical, predictable SSE connection cleanup isn’t just nice to have — it’s essential for a good user experience. The beforeunload solution ensures that players who leave are immediately removed from the game’s state, regardless of which browser they’re using.

If you’d like to see server sent events in action, try your hand solving AI authored mysteries with Whodunit. If you want to go straight to the source to bring SSE into your project, head over to sse-go. If you want help making your projects a reality, get in touch.