SSE with fetch + ReadableStream: authenticated streaming without EventSource

While building the ClaudeGate web playground, I needed to display Claude's response in real time — token by token, like on claude.ai. The Go API exposes an SSE endpoint. On the browser side, the first instinct is to reach for EventSource, the native browser API for consuming Server-Sent Events. Two minutes in, I had to give up. EventSource doesn't support custom headers. No X-API-Key, no authentication. Dead end.

The solution: fetch + ReadableStream. Same SSE protocol, same events, but with full control over headers. It's not necessarily more complex to write, just less well-known. Here's how it works end to end.

Why EventSource is a dead end for protected APIs

EventSource is the standard API for consuming SSE. The interface is clean:

const es = new EventSource('/api/v1/jobs/123/sse')
es.onmessage = e => console.log(e.data)
es.onerror = e => console.error('Error', e)

Simple. Automatic reconnection included. Excellent browser support. But there's a fundamental constraint: EventSource only makes GET requests, with no way to add headers. Header-based authentication (X-API-Key, Authorization: Bearer ...) is not possible.

Some work around this by passing the token in the URL (?token=xxx). That's a bad idea: the token shows up in server logs, browser history, and potentially in Referer headers. As soon as you need proper authentication, EventSource falls short.

fetch + ReadableStream: reading the body as a stream

With fetch, the HTTP response arrives normally — but instead of waiting for the body to be complete, you can read it incrementally via response.body.getReader(). That's where things get interesting for SSE.

var ctrl = new AbortController()

fetch('/api/v1/jobs/' + jobId + '/sse', {
    headers: authHeaders(),   // X-API-Key or Authorization: Bearer
    signal: ctrl.signal
})
.then(function(r) {
    var reader = r.body.getReader()   // read the body as a stream
    var decoder = new TextDecoder()
    var buf = ''

    function pump() {
        reader.read().then(function(chunk) {
            if (chunk.done) { return }                        // connection closed server-side
            buf += decoder.decode(chunk.value, { stream: true })
            var parts = buf.split('\n\n')                     // split by SSE event boundary
            buf = parts.pop()                                 // keep the incomplete fragment
            parts.forEach(parseSSEChunk)                      // process each complete event
            pump()                                            // async recursive call
        })
    }
    pump()
})

A few key points about this code:

r.body.getReader() — instead of waiting for r.text() or r.json() to return the full body, you get a stream reader. Each call to reader.read() returns a Promise that resolves as soon as bytes arrive, even if the stream is still open.

buf.split('\n\n') — this is the event separator in the SSE protocol. A complete event looks like event: chunk\ndata: {"text":"..."}\n\n. We split on \n\n, process the complete parts, and keep the last fragment (potentially truncated) in buf for the next iteration.

The pump() recursion — this isn't real recursion in the call stack sense. Each call to pump() is inside a .then() callback: the function returns immediately, the stack is freed, and the callback is invoked later by the JavaScript engine. No stack overflow possible, even on long streams. This is the idiomatic pattern for reading an infinite stream.

The manual SSE parser

Each extracted chunk is a raw SSE event. EventSource parses this format automatically. With fetch, that's on us.

The SSE format sent by the Go server looks like this:

event: chunk
data: {"text":"Hello"}

event: chunk
data: {"text":" world"}

event: result
data: {"status":"completed","result":"Hello world"}

The parser reads lines one by one, extracts event: and data:, then dispatches based on event type:

function parseSSEChunk(raw) {
    // raw = "event: chunk\ndata: {\"text\":\"Hello\"}"
    var lines = raw.split('\n')
    var eventName = '', dataStr = ''

    for (var k = 0; k < lines.length; k++) {
        if (lines[k].startsWith('event: '))      eventName = lines[k].slice(7)
        else if (lines[k].startsWith('data: '))  dataStr   = lines[k].slice(6)
    }

    if (!dataStr) { return }  // empty line or SSE comment

    var data = JSON.parse(dataStr)

    if (eventName === 'chunk') {
        responseBox.textContent += data.text   // display tokens as they arrive
    } else if (eventName === 'result') {
        // job finished — data contains the final result
        console.log('Done:', data.result)
    } else if (eventName === 'error') {
        console.error('Job error:', data.message)
    }
}

The SSE format on the Go server side

On the server side, emitting SSE events in Go is straightforward. The one non-negotiable requirement: call Flush() after each event. Without it, the data stays in the HTTP buffer and doesn't arrive in real time.

func writeSSEEvent(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) {
    payload, _ := json.Marshal(data)
    fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, payload)
    flusher.Flush() // flush immediately without waiting for the HTTP buffer
}

func StreamSSE(w http.ResponseWriter, r *http.Request) {
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming not supported", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    // Subscribe to job events...
    ch := broker.Subscribe(jobID)
    defer broker.Unsubscribe(jobID, ch)

    for {
        select {
        case event, ok := <-ch:
            if !ok {
                return  // channel closed = job finished
            }
            writeSSEEvent(w, flusher, event.Type, event.Data)
        case <-r.Context().Done():
            return  // client disconnected
        }
    }
}

The Content-Type: text/event-stream header tells the browser it's an SSE stream. Cache-Control: no-cache prevents proxies from buffering the stream.

AbortController — clean cancellation on both sides

AbortController lets you cancel the fetch connection at any time. When the user closes the modal or clicks "Stop", we call ctrl.abort():

var ctrl = new AbortController()

// When starting the stream:
fetch(url, { signal: ctrl.signal, headers: authHeaders() })
    .then(/* pump() */)
    .catch(function(err) {
        if (err.name === 'AbortError') {
            console.log('Stream cancelled by user')
        } else {
            console.error('Network error:', err)
        }
    })

// To cancel:
function stopStream() {
    ctrl.abort()
}

Cancellation is clean on both sides. On the browser side, the TCP connection is closed immediately. On the Go side, the request context terminates: r.Context().Done() fires, the select in StreamSSE exits the loop, and the defer Unsubscribe cleans up the channel. No goroutine gets stuck.

The subtle point: { stream: true } in TextDecoder

One subtlety that can cause silent bugs with non-ASCII characters:

// ✅ Correct — handles UTF-8 characters split across chunk boundaries
decoder.decode(chunk.value, { stream: true })

// ❌ Incorrect — can corrupt multi-byte characters (emoji, accented chars) at chunk boundaries
decoder.decode(chunk.value)

UTF-8 encodes non-ASCII characters using 2 to 4 bytes. A network chunk can end in the middle of a multi-byte character. With { stream: true }, the decoder maintains internal state and waits for the next chunk to complete the character. Without this option, partial bytes get replaced with the Unicode replacement character (�) and the displayed text is corrupted.

The full end-to-end flow

Here's the complete sequence in ClaudeGate:

[User clicks Send]
    → fetch POST /api/v1/jobs              → CreateJob → SQLite + Queue
    → fetch GET  /api/v1/jobs/{id}/sse     → StreamSSE → Subscribe(id)
                                                ↕ Go channel
                                            Worker → notify("chunk")
                                                → reader.read() → textContent +=
                                            Worker → notifyAndClose("result")
                                                → channel closed
                                                → chunk.done = true
                                                → pump() stops

Two fetch requests: one to create the job (POST), one to listen for events (GET SSE). The Go worker processes the job in a separate goroutine and pushes events into a channel. The SSE handler reads that channel and sends events to the browser. When the job finishes, the channel is closed, chunk.done becomes true, and the pump() loop stops.

EventSource vs fetch + ReadableStream comparison

EventSource fetch + ReadableStream
Custom headers
Auth Bearer / API Key
HTTP method GET only GET, POST, PUT…
Automatic reconnection ❌ (implement manually)
Automatic SSE parsing ❌ (manual parser)
Simple API More verbose
Browser support Excellent Excellent
Explicit cancellation es.close() ctrl.abort()

EventSource is still the right choice for public streams that don't need authentication. The moment you need headers — API Key, Bearer token, or any form of header-based auth — fetch + ReadableStream is the way to go. The code is slightly more verbose, but the pattern is straightforward once you've seen it once.

Conclusion

The frustration with EventSource is common among developers who discover SSE in the context of a secured API. The API is well-designed but deliberately constrained — it follows the same model as <img> or <script> tags, without custom headers. That's a spec decision, not an oversight.

fetch + ReadableStream isn't a hack. It's the recommended approach for authenticated HTTP streaming. The manual SSE parser is about twenty lines and covers all real-world cases. The rest — the pump() loop, splitting on \n\n, AbortController — are standard patterns found in every modern SSE client.

In ClaudeGate, this pattern handles streaming Claude's responses from the web playground. The perceived latency is zero: tokens appear as they arrive, without waiting for the full response. That's exactly what we were after.

Comments (0)