Skip to content
gohacks.dev
Concurrency

AfterFunc and WithoutCancel Context Helpers in Go 1.21

Use context.AfterFunc to run cleanup when a context ends and context.WithoutCancel to detach work from a request, with runnable examples and traps.

4 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Two helpers added in Go 1.21 solve problems that people used to work around by hand. One lets you attach a callback to a context's end. The other lets a piece of work keep a request's identity but ignore its cancellation. Both are small, and both are easy to misuse in ways the documentation points out.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This note shows each with a running example. It follows context explained and cancellation in web services, and it belongs to the concurrency material. Everything ran on Go 1.25.6.

AfterFunc: run this when the context ends

Before 1.21, reacting to a context ending meant a goroutine: go func() { <-ctx.Done(); cleanup() }(). That goroutine lives until the context ends, whether or not you still care. context.AfterFunc registers the callback without a dedicated waiting goroutine; the runtime starts a goroutine for f only when the context is done.

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	done := make(chan struct{})
	context.AfterFunc(ctx, func() {
		fmt.Println("cleanup: releasing resources")
		close(done)
	})

	fmt.Println("working...")
	time.Sleep(10 * time.Millisecond)
	cancel()

	<-done
	fmt.Println("callback ran after cancellation")
}
Text
working...
cleanup: releasing resources
callback ran after cancellation

The callback ran once the context was cancelled, in its own goroutine. A classic use is making something that does not understand contexts respond to cancellation: closing a connection or setting a deadline on a socket when the context ends, so a blocked read returns:

main.goGo
package main

import (
	"context"
	"fmt"
	"net"
	"time"
)

func main() {
	client, server := net.Pipe()
	defer server.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel()

	// Unblock the read when the context ends.
	stop := context.AfterFunc(ctx, func() { client.SetReadDeadline(time.Unix(1, 0)) })
	defer stop()

	_, err := client.Read(make([]byte, 1)) // would block forever: nobody writes
	fmt.Println("read returned:", err != nil, "| context:", ctx.Err())
}
Text
read returned: true | context: context deadline exceeded

A read on a connection has no context parameter, so the callback sets an expired deadline when the context ends, which makes the read return with a timeout error. The same idea is how libraries make blocking calls respond to cancellation, and now you can do it for any API that lacks a context parameter.

The stop function

AfterFunc returns a stop func() bool. Calling it unregisters the callback if it has not started yet and returns true; it returns false if the callback has already started or the function was stopped before. Use it when the operation finished normally and the callback is no longer needed:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	stop := context.AfterFunc(ctx, func() { fmt.Println("this should not run") })

	fmt.Println("stopped before it ran:", stop())
	cancel()
	time.Sleep(10 * time.Millisecond)
	fmt.Println("stopped a second time:", stop())
}
Text
stopped before it ran: true
stopped a second time: false

The first stop() cancels the registration, returning true; after the cancel, nothing runs; a second stop() returns false. If stop() returns false because the callback is already running, stop does not wait for it to finish; synchronise with a channel if you need to.

WithoutCancel: keep the values, drop the cancellation

A request context carries a trace ID and is cancelled when the handler returns. Work that should continue, sending a receipt, writing an audit log, needs the ID but not the cancellation. context.WithoutCancel(parent) returns a context that is never done, has no deadline and no error, and answers Value lookups from the parent:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

type key struct{}

func main() {
	req, cancelReq := context.WithCancel(context.WithValue(context.Background(), key{}, "req-42"))
	detached := context.WithoutCancel(req)

	cancelReq() // the handler returns

	fmt.Println("request context:", req.Err())
	fmt.Println("detached context:", detached.Err(), "| value:", detached.Value(key{}))
	fmt.Println("detached has a deadline:", func() bool { _, ok := detached.Deadline(); return ok }())

	bounded, cancel := context.WithTimeout(detached, 50*time.Millisecond)
	defer cancel()
	_, ok := bounded.Deadline()
	fmt.Println("with its own timeout:", ok)
}
Text
request context: context canceled
detached context: <nil> | value: req-42
detached has a deadline: false
with its own timeout: true

The request context is cancelled and the detached one is not, yet it still carries the request ID. The last lines show the necessary companion: a detached context never ends on its own, so give the work its own deadline with WithTimeout. Skipping that is how you get a goroutine that hangs forever on a dead dependency.

Typical use in a handler:

Go
go func() {
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second)
	defer cancel()
	sendReceipt(ctx, order)
}()

What to remember

Detaching is a deliberate decision to keep going after the caller is gone, so do it rarely and bound it. A detached goroutine dies with the process: a deploy in the middle loses it. For work that matters, such as emails and payments, write a job to a queue inside the request and let a worker do it. And AfterFunc's callback runs in its own goroutine, so it needs to be safe to run concurrently with the code that cancelled the context. The references are the AfterFunc and WithoutCancel documentation, and the Go 1.21 release notes introduce both.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.