gofiber/fiber · info · errStreamClosed

sse: stream closed

Error message

sse: stream closed

What it means

Declared as errStreamClosed in sse/sse.go and returned by Stream.Event/Comment/Retry (via Stream.write) once the stream is marked isClosed. A stream closes either because a prior write/flush failed (failLocked sets isClosed and cancels the context) or because closeStream ran when the handler returned. It is a normal lifecycle signal that the client is gone or the stream is no longer writable, not a bug.

Source

Thrown at middleware/sse/sse.go:21

// The package focuses on the SSE transport: response headers, wire formatting,
// flushing, heartbeat comments, and disconnect detection via flush errors.
// Application-specific concerns such as topics, replay storage, authentication,
// and pub/sub fan-out intentionally stay outside the core middleware.
package sse

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/gofiber/fiber/v3"
	"github.com/gofiber/utils/v2"
)

var errStreamClosed = errors.New("sse: stream closed")

// New creates a new SSE handler.
func New(config ...Config) fiber.Handler {
	cfg := configDefault(config...)
	if cfg.Handler == nil {
		panic("sse: Handler must not be nil")
	}

	return func(c fiber.Ctx) error {
		c.Set(fiber.HeaderContentType, fiber.MIMETextEventStream)
		c.Set(fiber.HeaderCacheControl, "no-cache")
		c.Set(fiber.HeaderConnection, "keep-alive")
		c.Set("X-Accel-Buffering", "no")

		streamContext := c.Context()
		lastEventID := c.Get(fiber.HeaderLastEventID)

		c.Abandon()

View on GitHub (pinned to 9a4c7e57fe)

Solutions

  1. Check stream.Err() / select on stream.Done() (or stream.Context().Done()) in your producer loop and stop writing when the stream ends.
  2. Treat errStreamClosed (errors.Is) as a clean disconnect and exit the handler goroutine; do not log it as an error.
  3. If using heartbeats, rely on the heartbeat goroutine to detect the disconnect via its own Comment() error.

Example fix

// before
for _, ev := range events {
    stream.Event(ev) // keeps writing after client gone
}

// after — stop when the stream ends
for _, ev := range events {
    if err := stream.Event(ev); err != nil {
        if errors.Is(err, sse.ErrStreamClosed) { break }
        return err
    }
}
Defensive patterns

Strategy: try-catch

Type guard

// Check stream liveness before writing.
func streamAlive(s *sse.Stream) bool {
    select {
    case <-s.Done():
        return false
    default:
        return s.Err() == nil
    }
}

Try / catch

if err := stream.Event(ev); err != nil {
    if errors.Is(err, sse.ErrStreamClosed) { // note: errStreamClosed is unexported
        return nil // client disconnected — stop the producer
    }
    return err
}

Prevention

When it happens

Trigger: Continuing to call stream.Event()/Comment()/Retry() after the client disconnected, after a heartbeat flush failed, or after the handler goroutine is winding down. Common when a producer goroutine keeps pushing events without observing Stream.Done()/Stream.Err().

Common situations: Long-lived SSE endpoints where the client closes the tab; mobile clients dropping connection; heartbeats failing through buffering proxies; producers that don't check stream state in a loop.

Related errors


AI-assisted analysis of gofiber/fiber@9a4c7e57fe (2026-08-04). Data as JSON: /data/errors/06edfec35f45f467.json. Report an issue: GitHub.