n4nAI

Graceful shutdown of streaming goroutines in Go

Learn how to implement go graceful shutdown streaming goroutines in production Go services with context cancellation, channels, and clean exit.

n4n Team3 min read706 words

Audio narration

Coming soon — every post will get a voice note here.

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.WithTimeout around srv.Shutdown) so a stuck worker cannot block process exit forever.
  • Use defer to release resources (tickers, response bodies, file handles) inside every worker.
  • If you use errgroup, it wraps WaitGroup and context cancellation in one primitive—use it when the fan-out logic gets complex.
  • Never call runtime.Goexit or panic to escape a streaming loop; return through the ctx.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.

Tagsgolanggoroutinesgraceful-shutdownstreaming

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All go streaming with goroutines & channels posts →