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.

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.
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")
}working...
cleanup: releasing resources
callback ran after cancellationThe 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:
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())
}read returned: true | context: context deadline exceededA 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:
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())
}stopped before it ran: true
stopped a second time: falseThe 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:
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)
}request context: context canceled
detached context: <nil> | value: req-42
detached has a deadline: false
with its own timeout: trueThe 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 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.


