Resumable Live Feeds with SSE and Redis Streams in Go

Oct 14, 2026 · 12 min read

Learn how to build a resumable real-time feed in Go using net/http, Server-Sent Events, and Redis Streams, including reconnects, replay cursors, and multi-node delivery.

Resumable Live Feeds with SSE and Redis Streams in Go

A live dashboard is easy to build when connections never drop, pages never reload, and the application runs on one server. Real systems don’t offer those guarantees.

I wanted a feed where new entries appear immediately, but a brief network outage or a page refresh shouldn’t make the user miss what happened. In Go, we can build this with the standard library’s net/http, Server-Sent Events (SSE), and Redis Streams. SSE keeps the HTTP connection open; Redis gives us an event history that clients can replay from.

The problem

Imagine a dashboard where new submissions appear as soon as someone creates them. Several failure cases show up quickly:

  • A user’s Wi-Fi drops for 10 seconds. Do they miss the events created during that time?
  • The user reloads the tab. Can the feed continue from where it left off?
  • The service runs on multiple Go instances. Can every instance deliver events from the same history?
  • An item is created between loading the current database state and starting the live subscription. Can that event disappear in the gap?

We need more than a persistent connection. We need a cursor, a replayable event log, and a way to recover when the cursor is too old. The database remains the source of truth for current state; Redis Streams provides a bounded history for quick replay.

Why SSE instead of WebSockets?

The feed is one-directional: the server sends updates to the browser. The browser doesn’t need a bidirectional channel for this use case, so Server-Sent Events are a good fit.

SSE runs over ordinary HTTP. Browsers use EventSource to connect and reconnect automatically after many network failures. If the server assigns an id: to each event, the browser sends the last received ID in the Last-Event-ID header when it reconnects.

That gives us a resume cursor, but not the history itself. The server still needs to answer: “What happened after this ID?” That’s where Redis Streams comes in.

Why Redis Streams?

Redis Pub/Sub is fire-and-forget. If a subscriber is disconnected when a message is published, that subscriber won’t receive it later. That’s useful for ephemeral signals, but not for a feed that must recover missed entries.

A Redis Stream is an append-only log. Each entry gets an ID such as 1718000000000-0, and the application can ask Redis for entries after a known ID.

SSE conceptRedis Streams concept
The id: field on an eventThe stream entry ID
Last-Event-ID on reconnectThe cursor to resume from
“What did I miss?”XRANGE after the cursor

We use the Redis entry ID as the SSE event ID. The browser’s cursor and the stream’s replay position now refer to the same point in history.

Architecture at a glance

The flow has four parts: producers append events, Redis retains recent history, a Go server reads new entries, and SSE connections deliver them to browsers.

flowchart TD
    P["Producer"] -->|XADD| S[("Redis Stream<br/>one per room")]
    S --> R["One XREAD loop<br/>per Go instance"]
    R -->|fan-out| A["Browser A (SSE)"]
    R -->|fan-out| B["Browser B (SSE)"]
    R -->|fan-out| C["Browser C (SSE)"]

The key design choices are:

  • One stream per room, with keys such as room:<id>:q.
  • One background XREAD loop per Go instance, rather than one blocking Redis read per browser connection.
  • A dedicated Redis client for blocking reads, so a blocked command doesn’t interfere with ordinary application commands.
  • A database fallback when a client’s cursor is older than the retained stream history.

The snippets below use idiomatic Go conventions and an http.Handler-based design. Redis operations are shown with github.com/redis/go-redis/v9; wire up the repository and Redis client to your own application.

Part 1: The HTTP handler

In Go, an HTTP handler is a function that satisfies http.Handler. The handler’s job is to validate the request, resolve the resume cursor, and stream events to the client.

func (h *Handler) ServeRoomEvents(w http.ResponseWriter, r *http.Request) {
    roomID := r.PathValue("roomID") // Go 1.22+ ServeMux path value

    lastEventID := r.Header.Get("Last-Event-ID")
    if lastEventID == "" {
        lastEventID = r.URL.Query().Get("resumeFrom")
    }

    if !h.authorizeRoom(r.Context(), r, roomID) {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }

    h.stream.Watch(w, r, roomID, lastEventID)
}

For example, register it with the standard library’s http.ServeMux:

mux := http.NewServeMux()
mux.HandleFunc("GET /rooms/{roomID}/stream", handler.ServeRoomEvents)

server := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
}

r.PathValue is available with the Go 1.22+ ServeMux routing patterns. If you’re using another router, get the room ID using that router’s parameter API.

Authentication with EventSource

The native browser EventSource API doesn’t let us set arbitrary request headers, which makes a custom Authorization: Bearer ... header awkward. One option is a short-lived, room-scoped token minted by a normal authenticated endpoint just before opening the stream.

The handler must validate that token and verify that its room claim matches the requested room. Avoid putting long-lived credentials in URLs: query strings can appear in access logs and other telemetry. Keep stream tokens short-lived, narrowly scoped, and redacted from logs where possible.

Part 2: The connect sequence—and the subtle race

A naive connection flow is:

  1. Load the current state from the database.
  2. Start listening for new events.

There is a race between those steps. If an item is created after the snapshot is loaded but before the live subscription starts, it may be missing from the snapshot and may have been published before the listener began. The client never sees it.

The fix is to record the stream tail first and use that cursor to bridge the snapshot and the live feed:

  1. Read the latest ID in the stream.
  2. Load the current snapshot from the database.
  3. Send the snapshot with the recorded tail as its SSE event ID.
  4. Start reading events after that recorded tail.
type Event struct {
    ID   string `json:"id"`
    Type string `json:"type"`
    Data any    `json:"data"`
}

func writeSSE(w http.ResponseWriter, eventType, id string, data []byte) error {
    if id != "" {
        if _, err := fmt.Fprintf(w, "id: %s\n", id); err != nil {
            return err
        }
    }
    if eventType != "" {
        if _, err := fmt.Fprintf(w, "event: %s\n", eventType); err != nil {
            return err
        }
    }

    if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
        return err
    }

    if flusher, ok := w.(http.Flusher); ok {
        flusher.Flush()
    }
    return nil
}

This helper illustrates the SSE wire format. In production, validate event IDs and event types, handle JSON encoding centrally, and ensure data is correctly formatted if payloads can contain newlines.

The stream tail can be read using Redis’s reverse-range command:

func currentTail(ctx context.Context, rdb *redis.Client, key string) (string, error) {
    entries, err := rdb.XRevRangeN(ctx, key, "+", "-", 1).Result()
    if err != nil && !errors.Is(err, redis.Nil) {
        return "", fmt.Errorf("read stream tail: %w", err)
    }
    if len(entries) == 0 {
        return "", nil
    }
    return entries[0].ID, nil
}

The important part is the order: record a cursor before loading the snapshot, then tail forward from that cursor. Depending on the exact handoff implementation, an event near the boundary may be delivered twice, so clients should deduplicate by event ID. A duplicate can be handled; a silent gap is much harder to recover from.

Part 3: Reconnecting and replaying missed events

When the browser reconnects with a Last-Event-ID, the server asks Redis for entries after that ID. With go-redis/v9, the exclusive lower bound is represented by prefixing the ID with (.

func readMissed(
    ctx context.Context,
    rdb *redis.Client,
    key string,
    lastID string,
) ([]redis.XMessage, error) {
    messages, err := rdb.XRange(
        ctx,
        key,
        "("+lastID,
        "+",
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("replay stream from %q: %w", lastID, err)
    }
    return messages, nil
}

This works only if the cursor is still within the stream’s retained history. Streams are often capped with MAXLEN so they don’t grow forever. If the client has been offline too long, its cursor might have been trimmed out.

The trap: incomplete replay

A trimmed cursor is dangerous because XRANGE may return entries that remain without telling you that earlier entries are missing. The response can look successful while the client’s history is incomplete.

Before replaying, inspect the stream’s first retained entry with XINFO STREAM. If the cursor predates the retained window—or the stream no longer exists—fall back to a fresh database snapshot.

Redis stream IDs have the form <milliseconds>-<sequence>. Compare both components numerically, not as plain strings:

func compareStreamIDs(a, b string) (int, error) {
    parse := func(id string) (uint64, uint64, error) {
        parts := strings.Split(id, "-")
        if len(parts) != 2 {
            return 0, 0, fmt.Errorf("invalid stream ID %q", id)
        }

        ms, err := strconv.ParseUint(parts[0], 10, 64)
        if err != nil {
            return 0, 0, fmt.Errorf("parse stream timestamp: %w", err)
        }

        seq, err := strconv.ParseUint(parts[1], 10, 64)
        if err != nil {
            return 0, 0, fmt.Errorf("parse stream sequence: %w", err)
        }
        return ms, seq, nil
    }

    aMS, aSeq, err := parse(a)
    if err != nil {
        return 0, err
    }
    bMS, bSeq, err := parse(b)
    if err != nil {
        return 0, err
    }

    switch {
    case aMS < bMS:
        return -1, nil
    case aMS > bMS:
        return 1, nil
    case aSeq < bSeq:
        return -1, nil
    case aSeq > bSeq:
        return 1, nil
    default:
        return 0, nil
    }
}

The recovery policy has two paths:

  • Cursor retained: replay missed events from Redis. This is the fast path for short interruptions.
  • Cursor trimmed or stream missing: reload the authoritative state from the database and send a resync event.

A snapshot or resync should replace the browser’s current list. An item event should append a new item. Using distinct event types makes that contract explicit.

Part 4: One XREAD loop per Go instance

A blocking Redis read for every SSE connection creates unnecessary connections and work. Instead, each Go instance runs one background loop that reads the rooms it currently watches and fans events out to local subscribers.

A simplified loop using go-redis/v9 looks like this:

func runReadLoop(
    ctx context.Context,
    rdb *redis.Client,
    keys []string,
    cursors map[string]string,
    publish func(streamKey string, message redis.XMessage),
) error {
    for {
        if err := ctx.Err(); err != nil {
            return err
        }
        if len(keys) == 0 {
            return nil
        }

        streams := make([]string, 0, len(keys)*2)
        streams = append(streams, keys...)
        for _, key := range keys {
            cursor := cursors[key]
            if cursor == "" {
                cursor = "0-0"
            }
            streams = append(streams, cursor)
        }

        results, err := rdb.XRead(ctx, &redis.XReadArgs{
            Streams: streams,
            Count:   100,
            Block:   time.Second,
        }).Result()

        if err != nil && !errors.Is(err, redis.Nil) {
            if ctx.Err() != nil {
                return ctx.Err()
            }
            return fmt.Errorf("read Redis streams: %w", err)
        }

        for _, stream := range results {
            for _, message := range stream.Messages {
                cursors[stream.Stream] = message.ID
                publish(stream.Stream, message)
            }
        }
    }
}

This is a simplified illustration, not a drop-in service: the watch list and cursor map need synchronization if subscriptions can change concurrently. Production code should also handle transient Redis errors with bounded retry/backoff and should not terminate the entire reader on a temporary failure.

A few implementation details matter:

  • Use a finite block timeout. Block: time.Second lets the loop re-check its watch list. An infinite block can make newly watched rooms wait indefinitely.
  • Use a dedicated Redis client for blocking reads. Keep normal application commands on another connection.
  • Start the loop lazily and stop it when idle. If there are no subscribers, avoid unnecessary Redis traffic.
  • Keep cursors per instance. Multiple Go instances can independently read the same shared streams and serve their own connected browsers.

In Go, lifecycle management should be explicit. Use context.Context to cancel the loop during shutdown, and coordinate goroutines with a sync.WaitGroup or an equivalent service lifecycle mechanism.

Part 5: Heartbeats

Idle SSE connections can be closed by proxies or load balancers. A heartbeat comment every 15 seconds helps keep the connection warm:

func writeHeartbeat(w http.ResponseWriter) error {
    if _, err := io.WriteString(w, ": ping\n\n"); err != nil {
        return err
    }

    if flusher, ok := w.(http.Flusher); ok {
        flusher.Flush()
    }
    return nil
}

A handler can use a ticker and stop it when the request context is canceled:

ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()

for {
    select {
    case <-r.Context().Done():
        return
    case <-ticker.C:
        if err := writeHeartbeat(w); err != nil {
            return
        }
    }
}

The heartbeat is an SSE comment, not a normal event. It has no id: field, so it doesn’t overwrite the client’s resume cursor. In a real handler, integrate heartbeat writes with the event delivery loop so two goroutines never write to the same ResponseWriter concurrently.

Part 6: Not everything belongs in the stream

Some signals represent state changes rather than replayable history—for example, “item X was hidden” or “item X was marked done.” We handle those separately with Redis Pub/Sub, then fan them out through the same local subscriber registry.

Two rules keep this separation safe:

  • Pub/Sub signals carry no SSE event ID. They aren’t replayable history and must not become a Last-Event-ID. On reconnect, the snapshot or resync already reflects the latest state.
  • Pub/Sub uses a dedicated Redis connection. A client in subscriber mode isn’t a general-purpose command connection.

That leaves separate connections for ordinary application commands, blocking stream reads, and Pub/Sub subscriptions. This isolation avoids a blocking read or subscription interfering with unrelated commands.

We also keep high-frequency “latest value wins” data—such as reaction counters—out of the event history. Noisy updates can push meaningful events out of a capped stream’s replay window. Separating those signals protects the history that clients rely on to recover.

Client side, briefly

The browser stores the last handled event ID in sessionStorage. When a page reloads, it passes that cursor as resumeFrom while creating a new EventSource.

let lastID = sessionStorage.getItem("lastEventID");

const url = new URL(`/rooms/${roomID}/stream`, API);
url.searchParams.set("token", token);

if (lastID) {
  url.searchParams.set("resumeFrom", lastID);
}

const events = new EventSource(url);

events.addEventListener("snapshot", (event) => {
  items = JSON.parse(event.data); // Replace local state.
  save(event.lastEventId);
});

events.addEventListener("resync", (event) => {
  items = JSON.parse(event.data); // Replace local state.
  save(event.lastEventId);
});

events.addEventListener("item", (event) => {
  items.push(JSON.parse(event.data)); // Append one item.
  save(event.lastEventId);
});

function save(id: string) {
  sessionStorage.setItem("lastEventID", id);
}

The Go server emits the SSE protocol; the browser handles the connection and automatic reconnect behavior. The client still has to apply event types correctly:

  • snapshot replaces local state on the initial connection.
  • resync replaces local state after the replay cursor has expired.
  • item appends a new event.

In a production client, update local state and persist the cursor carefully. Don’t advance the saved cursor past events that haven’t been safely applied, and deduplicate items by ID if the server’s snapshot-to-stream handoff can produce duplicates.

Lessons learned

The biggest lessons from this design were:

  1. Use the stream ID as the SSE event ID. The client’s cursor and the server’s replay position now refer to the same value.
  2. Record the tail before reading the database. The order closes the race between the initial snapshot and live delivery.
  3. Validate the cursor against retained history. A successful XRANGE can still be incomplete after trimming.
  4. Keep a database fallback. A bounded stream plus a fresh snapshot lets clients recover after long outages.
  5. Use one blocking reader per instance on a dedicated connection. Don’t let blocking reads interfere with normal commands.
  6. Separate replayable history from ephemeral signals. Streams retain history; Pub/Sub distributes state-change signals that don’t need replay.
  7. Make goroutine lifecycles explicit. Use contexts for cancellation, synchronize shared maps, and handle transient Redis failures deliberately.

These choices matter most when the system leaves the happy path: a browser disconnects, a stream is trimmed, or multiple Go instances serve the same room.

Wrapping up

The result is a live feed with at-least-once delivery, automatic recovery from brief network interruptions, a database-backed recovery path after longer outages, and support for multiple server instances—all on top of plain HTTP.

The reliability doesn’t come from SSE alone or Redis Streams alone. SSE provides a reconnecting browser connection, Redis Streams supplies replayable event history, and the database lets us rebuild state when a cursor has expired.

If you’re building a live list that needs to recover rather than quietly lose updates, this combination is worth considering.

Go Redis Backend Real-time Systems
Available for opportunities

Need someone who enjoys solving hard engineering problems?

I enjoy building reliable systems, understanding complex architectures, and solving challenging engineering problems. If you're looking to hire a software engineer who combines curiosity, ownership, and strong technical fundamentals, I'd love to hear from you.

deploy-status

> Status: Available

> Curiosity: HIGH

> Auto Scaling: Enabled