Killing a streaming goroutine with os.Exit or a bare channel close leaks connections and truncates responses mid-stream. Proper go graceful shutdown streaming goroutines demands explicit coordination between context cancellation, channel draining, and waitgroup signaling so your service stops accepting new work and lets in-flight streams finish or abort cleanly.
Step 1: Create a cancellable root context
Every long-running process in Go should hang off a context.Context that you control. For a server, signal.NotifyContext is the cleanest way to convert SIGINT/SIGTERM into cancellation without writing your own signal loop.
package main
import (
"context"
"os"
"os/signal"
"syscall"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
run(ctx)
}
Pass ctx to every goroutine you spawn. Do not store it in a global; thread it through function parameters. This is the backbone of go graceful shutdown streaming goroutines because it gives you a single knob to tell every worker to stop.
Step 2: Spawn workers that select on ctx.Done
A streaming goroutine must never block on a channel send without also watching the context. The moment ctx.Done() closes, the worker should return. Use a sync.WaitGroup so the parent can wait for clean exit.
func streamWorker(ctx context.Context, out chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case <-ticker.C:
select {
case out <- i:
case <-ctx.Done():
return
}
}
}
}
The nested select on the send is critical. If the consumer has already stopped reading, an unbuffered send would hang the worker forever, defeating the shutdown.
Step 3: Fan-out and close the channel safely
You cannot close a channel from multiple workers. Spawn a dedicated goroutine that waits on the WaitGroup and then closes the output channel. This lets a range loop in the consumer terminate naturally.
func run(ctx context.Context) {
out := make(chan int, 16)
var wg sync.WaitGroup
for w := 0; w < 3; w++ {
wg.Add(1)
go streamWorker(ctx, out, &wg)
}
go func() {
wg.Wait()
close(out)
}()
for v := range out {
// forward to client, log, or aggregate
_ = v
}
}
Buffered channels reduce contention but do not substitute for context checks. Size the buffer for expected burst, not for infinite backlog.
Step 4: Wire context into an HTTP server
If you expose the stream over HTTP, the server itself must shut down gracefully. Start the server in a goroutine and trigger Shutdown when the root context cancels. Give it a bounded timeout.
srv := &http.Server{Addr: ":8080", Handler: handler}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
// log forced close
}
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// log fatal
}
http.Server.Shutdown waits for active requests to complete within the timeout. Those requests only complete if your handlers respect their own context.
Step 5: Propagate client disconnects
The request context (r.Context()) cancels automatically when the client closes the connection. Your streaming handler must watch it, or you will keep generating data for a dead socket and leak goroutines.
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
out := make(chan int)
go func() {
defer close(out)
for i := 0; i < 100; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
w.Header().Set("Content-Type", "text/plain")
for v := range out {
fmt.Fprintf(w, "%d\n", v)
flusher.Flush()
}
}
If you are proxying token streams from an inference gateway like n4n.ai, the same rule applies: pass r.Context() into the upstream request. Their OpenAI-compatible endpoint will cancel the generation when the context expires, so your goroutine should stop reading the response body as soon as ctx.Err() is non-nil. That turns a client abort into a clean upstream cancellation instead of a wasted spend.
Common mistake: double close
Never close out inside the worker and also in the waiter goroutine. Pick one owner. The pattern in Step 3 assigns ownership to the waiter, which is safe because wg.Wait guarantees all senders have returned.
Step 6: Verify the shutdown behavior
A go graceful shutdown streaming goroutines implementation is worthless if you cannot prove it exits without leaks. Two checks cover most cases.
First, a manual smoke test with curl and a signal:
go run main.go &
SERVER_PID=$!
curl -N http://localhost:8080/ &
sleep 0.3
kill -INT $SERVER_PID
wait $SERVER_PID
You should see the process exit 0 (or 143 from SIGTERM) and no panic: send on closed channel in stderr. If curl is killed early, the server log should show no hung goroutines.
Second, a Go test that asserts cancellation propagates:
func TestStreamCancels(t *testing.T) {
req := httptest.NewRequest("GET", "/stream", nil)
ctx, cancel := context.WithCancel(req.Context())
req = req.WithContext(ctx)
w := httptest.NewRecorder()
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
handler(w, req)
if w.Code != http.StatusOK && w.Body.Len() == 0 {
t.Fatal("expected partial stream before cancel")
}
// In production, pair this with golang.org/x/net/context/ctxhttp or
// pprof goroutine counts to confirm no leak.
}
For continuous verification, expose runtime.NumGoroutine() via a debug endpoint in staging and alert if it climbs after repeated client disconnects.
Why context beats channel-only signaling
Channels alone force you to close a known set of channels and hope every goroutine is listening. Context is a broadcast mechanism built into the standard library: one cancel traverses an arbitrary tree of derived contexts. For streaming workloads where a single HTTP request may spawn child goroutines for parsing, forwarding, and metrics, context is the only scalable option.
Production notes
- Set a hard deadline on shutdown (
context.WithTimeoutaroundsrv.Shutdown) so a stuck worker cannot block process exit forever. - Use
deferto release resources (tickers, response bodies, file handles) inside every worker. - If you use
errgroup, it wrapsWaitGroupand context cancellation in one primitive—use it when the fan-out logic gets complex. - Never call
runtime.Goexitorpanicto escape a streaming loop; return through thectx.Done()branch.
Following these steps gives you a service where go graceful shutdown streaming goroutines is not a hope but a guaranteed property: signals are caught, clients disconnecting free upstream resources, and the process terminates with zero goroutine leaks.