Stop a Goroutine in Go Without Leaking the Whole Server
Go has no way to kill a goroutine, so ask it to stop: context, a done channel, closing its input, checks in CPU loops, unblocking I/O, and waiting.

Programmers new to Go look for the function that kills a goroutine and are surprised to learn there is none. go starts one, nothing stops one, and that is deliberate. A goroutine killed from outside could be in the middle of a map write, holding a lock, halfway through a protocol. The runtime offers no way to destroy it safely, so Go's answer is cooperation: the goroutine is asked to stop and it ends itself when it is at a point where that is safe.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article collects the ways to ask, the places where the asking fails, and how to wait for the answer. It follows goroutine leaks and context, and belongs to the concurrency material. Everything ran on Go 1.25.6.
Signal 1: a context
The default tool is a context. The goroutine takes a context.Context and selects on ctx.Done() wherever it waits:
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println("worker", id, "stopping:", ctx.Err())
return
case <-time.After(10 * time.Millisecond):
// do a unit of work
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
wg.Add(1)
go worker(ctx, 1, &wg)
time.Sleep(35 * time.Millisecond)
cancel() // ask it to stop
wg.Wait() // wait until it has
fmt.Println("stopped and joined")
}worker 1 stopping: context canceled
stopped and joinedThree steps: cancel, the goroutine notices and returns, and Wait confirms it has gone. The last step matters. cancel() only asks; until the goroutine has returned, it may still be running, touching shared data or holding resources. Stopping means asking and then waiting.
Signal 2: a done channel
Before contexts there was the done channel, and it is still right for self-contained components. A chan struct{} that the owner closes; closing broadcasts to every goroutine that selects on it:
package main
import (
"fmt"
"sync"
"time"
)
type Ticker struct {
done chan struct{}
wg sync.WaitGroup
once sync.Once
}
func Start(interval time.Duration, f func()) *Ticker {
t := &Ticker{done: make(chan struct{})}
t.wg.Go(func() {
tick := time.NewTicker(interval)
defer tick.Stop()
for {
select {
case <-tick.C:
f()
case <-t.done:
return
}
}
})
return t
}
// Stop ends the loop and waits for it to exit. It is safe to call twice.
func (t *Ticker) Stop() {
t.once.Do(func() { close(t.done) })
t.wg.Wait()
}
func main() {
n := 0
t := Start(10*time.Millisecond, func() { n++ })
time.Sleep(55 * time.Millisecond)
t.Stop()
t.Stop() // idempotent
fmt.Println("ticked at least 3 times before Stop:", n >= 3)
}ticked at least 3 times before Stop: trueThe type bundles the goroutine with its stop and wait. Stop closes done once (guarded by sync.Once so a second call is harmless) and waits for the goroutine to finish. This is the shape of nearly every well-behaved background component: Start or New launches, Stop or Close ends and joins. If a type starts a goroutine and offers no Stop, it cannot be used in a test or shut down cleanly (starting and stopping in order).
Signal 3: close the input
A goroutine that consumes from a channel ends naturally when the channel is closed and drained:
package main
import (
"fmt"
"sync"
)
func main() {
jobs := make(chan int)
var wg sync.WaitGroup
var sum int
wg.Go(func() {
for j := range jobs { // ends when jobs is closed
sum += j
}
})
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs) // the stop signal is "no more work"
wg.Wait()
fmt.Println("sum:", sum)
}sum: 15No separate stop mechanism is needed: closing the input is the stop signal. It ends the consumer after it has processed everything already queued, which is often what you want for a graceful finish. It does not interrupt a consumer in the middle of a long job, so combine it with a context when you need both "finish the queue" and "abort now".
Where stopping fails
CPU-bound loops that never check
A goroutine in a tight computation never reaches a select, so no signal reaches it. The fix is to check periodically:
package main
import (
"context"
"fmt"
"time"
)
func countPrimes(ctx context.Context, limit int) (count int, err error) {
for n := 2; n < limit; n++ {
if n%1000 == 0 { // check every so often, not every iteration
if err := ctx.Err(); err != nil {
return count, err
}
}
prime := true
for d := 2; d*d <= n; d++ {
if n%d == 0 {
prime = false
break
}
}
if prime {
count++
}
}
return count, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
start := time.Now()
_, err := countPrimes(ctx, 1_000_000_000)
fmt.Println("stopped:", err, "| within about", time.Since(start).Round(30*time.Millisecond))
}stopped: context deadline exceeded | within about 30msThe function would run for minutes, and returns within a few milliseconds of the deadline because it asks ctx.Err() every thousand iterations. Err() is cheap but not free, so checking at an interval bounds the delay without paying on each iteration. The Go scheduler preempts the goroutine so other goroutines still run (the scheduler), but preemption does not end it; only the check does.
Goroutines blocked in I/O
A read on a socket or a pipe does not select on a context. The goroutine is stuck in the system call until data arrives or the file is closed. The two ways to wake it are to close the resource from another goroutine, which makes the pending read return an error, or to set a deadline:
package main
import (
"context"
"fmt"
"net"
"sync"
"time"
)
func main() {
client, server := net.Pipe()
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
stop := context.AfterFunc(ctx, func() { client.Close() }) // wake the reader on cancel
defer stop()
var wg sync.WaitGroup
wg.Go(func() {
_, err := client.Read(make([]byte, 16)) // blocks: nobody writes
fmt.Println("reader woke up with an error:", err != nil)
})
time.Sleep(20 * time.Millisecond)
cancel()
wg.Wait()
fmt.Println("reader goroutine has exited")
}reader woke up with an error: true
reader goroutine has exitedcontext.AfterFunc closes the connection when the context ends, and the blocked Read returns an error, so the goroutine can return (AfterFunc). The standard library's net/http does the same for requests with contexts. For a goroutine blocked in a plain system call with no handle to close, such as a cgo call, you cannot interrupt it at all; the thread stays blocked until the call returns (cgo and blocked threads).
Goroutines blocked on a send
A producer blocked sending to a channel nobody reads ignores ctx.Done() unless the send is in a select. Always write sends as select { case ch <- v: case <-ctx.Done(): return } in goroutines that must be stoppable (the select guide has helpers).
Stopping many goroutines
Components rarely have one goroutine. Cancel one context shared by all of them, and wait on one WaitGroup for all; errgroup bundles both, and returns the first error (errgroup). Stop in dependency order: stop accepting new work first, then let in-flight work finish within a deadline, then cancel what remains, then close resources. Reversing that order drops work or panics on closed channels (send on closed channel and graceful shutdown).
Testing that it stops
A stop path that is never exercised is not a stop path. For every component with a Stop, write a test that starts it, stops it and checks that it stopped: the stop call returns within a short bound (a stuck stop is a bug), the work function stops being called afterwards, and no goroutines remain. The bound is the valuable part, because a stop that waits forever shows up as a hanging test, and a select with time.After(time.Second) around the call turns the hang into a failure with a message. To check that nothing is called afterwards, record a counter, stop, note the value, wait a few intervals and assert it did not change. With Go 1.25's testing/synctest the waiting costs no real time, since the fake clock advances when every goroutine in the bubble is blocked (leak tests with synctest). These three checks together are a cheap way to keep a component's lifecycle honest through refactors.
What not to do
Don't use a global flag variable without synchronisation; use an atomic or a channel. Don't call runtime.Goexit to "stop" another goroutine; it ends only the caller. Don't rely on os.Exit as the shutdown strategy for components that hold resources, since deferred functions do not run. Don't stop without waiting, which leaves a window in which the goroutine still runs. Don't write a goroutine you cannot stop unless it is meant to live for the whole process, and then say so in a comment.
A goroutine that can be told to stop, and a caller that waits for it, are the two halves of a clean lifecycle. The Go blog's Pipelines and cancellation is the classic treatment, and the language specification's section on go statements confirms that a program ends when main returns without waiting for other goroutines.


