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
- Check stream.Err() / select on stream.Done() (or stream.Context().Done()) in your producer loop and stop writing when the stream ends.
- Treat errStreamClosed (errors.Is) as a clean disconnect and exit the handler goroutine; do not log it as an error.
- 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
- In producer loops, select on stream.Done() / stream.Context().Done() and stop on disconnect.
- Treat write errors after a disconnect as info, not errors.
- Let the heartbeat goroutine detect disconnects if DisableHeartbeat is false.
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
- sse: write event: %w
- shutdown: graceful timeout has been reached, exiting
- shutdown: server is not running
- runtime.Goexit() called in handler or server panic
- field must not contain CR or LF
AI-assisted analysis of gofiber/fiber@9a4c7e57fe (2026-08-04).
Data as JSON: /data/errors/06edfec35f45f467.json.
Report an issue: GitHub.