Introduction

Early in my career I built a simple HTTP service that performed heavy data processing. When a client requested a report, I spawned a goroutine that ran the computation and sent the result back over a channel. The implementation worked fine for small loads, but under pressure the goroutine would stay alive indefinitely if the client disconnected or timed out. The result was a growing pool of idle workers that eventually exhausted system resources. The fix was surprisingly simple: use context to propagate cancellation signals and shut down background work cleanly.

The Problem

Without a built‑in way to abort a goroutine, developers often rely on channels to signal completion. A typical pattern looks like this:


func process(id int, ch chan<string) {
    // simulate work
    time.Sleep(5 * time.Second)
    ch <- "done"
}

func main() {
    ch := make(chan string)
    go process(1, ch)
    // wait for result or a timeout
    select {
    case <-ch:
        fmt.Println("result received")
    case <-time.After(2 * time.Second):
        fmt.Println("timeout")
    }
}

The goroutine continues to run even after the timeout. If the client aborts the request, we have no way to tell the worker to stop. This leads to resource leaks and, in high‑traffic services, can cause the whole application to grind to a halt.

Why Context Is the Right Tool

Context is Go’s idiomatic mechanism for carrying deadlines, cancellation signals, and request‑scoped values across API boundaries and goroutine hierarchies. It gives you two primary ways to stop work:

  • Cancellation – Done() channel closes when the operation should stop.
  • Timeout – WithTimeout or WithDeadline automatically cancels after a period.

By checking the context inside the goroutine, you can exit early, clean up resources, and avoid the leak. Moreover, context propagation mirrors the way HTTP handlers and gRPC services already pass request‑scoped data, making the mental model consistent.

A Clean Snippet for Safe Background Work

Below is a reusable helper I keep in a utility package. It starts a background task that respects a context and returns a channel that the caller can wait on. The function also guarantees that the underlying goroutine is always stopped when the context finishes, even if the caller never reads the result.


// worker.go
package util

import (
    "context"
    "fmt"
)

// RunWithContext spawns a goroutine that performs work until the supplied
// context is cancelled or times out. It returns a read‑only channel that will
// receive a single result value (or an error) when the work completes.
// If the context finishes before the work, the channel is closed with a
// context‑error to signal early termination.
func RunWithContext(ctx context.Context, fn func() (interface{}, error)) <-chan Result {
    out := make(chan Result)
    go func() {
        defer close(out)

        // Wait for either context cancellation or work completion.
        workDone := make(chan struct{})
        go func() {
            val, err := fn()
            workDone <- struct{}{}
            select {
            case out <- Result{Value: val, Err: err}:
            case <-ctx.Done():
                // Context already cancelled, drop the result.
            }
        }()

        select {
        case <-workDone:
            // Work finished first – result already sent.
        case <-ctx.Done():
            // Context cancelled/timeout – signal the caller.
            out <- Result{Err: ctx.Err()}
        }
    }()
    return out
}

// Result holds the output of the background function.
type Result struct {
    Value interface{}
    Err   error
}

Notice how the goroutine runs inside a select that watches both the work channel and ctx.Done(). If the context expires first, the function pushes a Result{Err: ctx.Err()} and the goroutine exits. If the work finishes first, its value is emitted, but the select still ensures we do not block forever when the context is already done.

Real‑World Use Case

Imagine an API endpoint that generates a PDF invoice. The PDF generation can take several seconds, and the client may abandon the request after a few hundred milliseconds (e.g., the user navigates away). The handler looks like this:


func GenerateInvoice(w http.ResponseWriter, r *http.Request) {
    // 500 ms deadline for the whole request.
    ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
    defer cancel()

    resultCh := util.RunWithContext(ctx, func() (interface{}, error) {
        return generatePDF(r.URL.Query().Get("id"))
    })

    select {
    case res := <-resultCh:
        if res.Err != nil {
            http.Error(w, res.Err.Error(), http.StatusRequestTimeout)
            return
        }
        // write PDF to response
        w.Write(res.Value.([]byte))
    case <-ctx.Done():
        // The context already expired – nothing to send.
        http.Error(w, "request timed out", http.StatusRequestTimeout)
    }
}

If the PDF generation finishes before the deadline, the client receives the file. If the deadline expires first, the context’s error propagates, the goroutine is stopped, and the handler returns a 408 without leaking resources. The same pattern works for database queries, external API calls, or any long‑running operation.

Tips and Gotchas

  • Never block on a channel read/write inside the background function without checking the context. A blocking operation will keep the goroutine alive even if the context is cancelled.
  • Use a buffered channel for the result. This prevents the goroutine from leaking if the caller never reads from the channel (e.g., because the context cancelled first).
  • Prefer context.WithCancel when you need an explicit cancellation signal (e.g., from an HTTP shutdown), and context.WithTimeout when you have a hard deadline. Both give you the same Done() channel.
  • Avoid using the same context in multiple independent tasks unless you intend them to share the same cancellation signal. Sharing can cause unexpected early termination.

Wrapping Up

Leaky goroutines are a classic source of hard‑to‑track bugs in concurrent Go services. By leveraging context.WithTimeout or context.WithCancel and checking the context inside background work, you gain a declarative way to stop operations that respects request lifetimes and cleans up resources automatically. The snippet above is a small, reusable building block that I keep in every Go project because it turns a potential resource leak into a robust, predictable behavior. Use it, adapt it, and you’ll find your services stay healthy even under heavy load and frequent cancellations.

If you start every background goroutine with a context in mind, you’ll spend far less time debugging “zombie” workers and more time building features that actually matter.