The cleanup problem
You are running a background worker that processes a queue of jobs. Halfway through a batch, the operator hits the stop button. The worker needs to release a temporary file, close a database transaction, or flush a metrics buffer before it exits. Before Go 1.21, you handled this by spinning up a select loop, wiring up a time.AfterFunc, and manually routing cancellation signals through a channel. The boilerplate grew quickly, and it was easy to introduce race conditions or forget to stop the timer. Go 1.21 added context.AfterFunc to collapse that pattern into a single call.
How context.AfterFunc works
context.AfterFunc schedules a callback to run exactly once when a context is canceled or a deadline passes. It ties the lifecycle of a piece of deferred work directly to the context that controls it. Think of it like a safety switch on a power tool: you set it to trigger when the main power cuts, but you can also flip it off manually before that happens. The function accepts a context and a callback, then returns a context.CancelFunc. Calling that returned function cancels the scheduled work. Calling the original context's cancel function triggers it.
Go conventions expect context values to flow through call stacks as the first parameter, usually named ctx. context.AfterFunc follows this pattern. It does not start a new context. It attaches to an existing one. This keeps the cancellation tree intact and avoids creating unnecessary context wrappers.
Context is plumbing. Run it through every long-lived call site.
A minimal example
Here is the simplest way to wire it up. You create a context, schedule a callback, and then cancel the context to see it fire.
package main
import (
"context"
"fmt"
"time"
)
func main() {
// Background context acts as the root cancellation signal.
ctx, cancel := context.WithCancel(context.Background())
// Schedule cleanup to run when ctx is canceled.
// The returned stop function can prevent execution.
stop := context.AfterFunc(ctx, func() {
fmt.Println("Cleanup executed")
})
// Simulate work happening elsewhere.
time.Sleep(100 * time.Millisecond)
// Trigger the scheduled callback.
cancel()
// Give the runtime a moment to run the callback.
time.Sleep(100 * time.Millisecond)
// If you called stop() before cancel(), the callback would never run.
_ = stop
}
The callback runs asynchronously. You do not block waiting for it to finish. The _ = stop line discards the unused variable to satisfy the compiler, which rejects programs with stop declared and not used.
What happens under the hood
When you call context.AfterFunc, the runtime registers a listener on the context's internal cancellation channel. It also sets up a timer if the context carries a deadline. The function does not execute your callback immediately. It queues it to run in a separate goroutine the moment the context becomes invalid. If you call the returned stop function, the runtime removes the listener and discards the callback. If you call the context's cancel function, the runtime fires the callback. The callback always runs at most once, even if you cancel the context multiple times.
The implementation uses Go's internal timer wheel and cancel notification system. This means it shares the same scheduling guarantees as time.AfterFunc, but with built-in context awareness. You do not need to manage a done channel or write a select block to bridge the timer and the context. The runtime handles the race between deadline expiration and manual cancellation.
The worst goroutine bug is the one that never logs.
Real-world usage: graceful shutdown hooks
Real systems rarely just print to stdout. They release resources, close connections, or write audit logs. Here is how you attach a cleanup hook to a long-running task without blocking the main execution path.
package main
import (
"context"
"fmt"
"time"
)
// ProcessBatch simulates a long-running job that needs cleanup.
func ProcessBatch(ctx context.Context) error {
// Schedule resource release to run on cancellation.
// This runs in a separate goroutine, so it won't block the caller.
release := context.AfterFunc(ctx, func() {
fmt.Println("Releasing temporary resources")
// Close files, rollback transactions, or flush buffers here.
})
// Simulate work that might be interrupted.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
fmt.Println("Batch completed successfully")
// Cancel the scheduled cleanup since we finished normally.
release()
return nil
}
}
func main() {
// Attach a timeout to simulate an operator killing the job.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
err := ProcessBatch(ctx)
if err != nil {
fmt.Println("Job interrupted:", err)
}
// Wait for the async cleanup callback to finish.
time.Sleep(100 * time.Millisecond)
}
Notice how release() is called when the job finishes normally. This prevents the cleanup callback from firing after the work is already done. The select block handles the actual cancellation signal, while AfterFunc handles the side effects. The receiver name convention does not apply here since this is a standalone function, but if you attached this logic to a struct method, you would use a short receiver name like (j *Job) ProcessBatch(ctx context.Context).
Trust the cancellation tree. Cancel once, clean up everywhere.
Pitfalls and runtime behavior
The callback runs in a new goroutine managed by the context package. If your callback blocks for a long time, it ties up a runtime goroutine and delays other scheduled callbacks. Keep the work lightweight. If you need to do heavy lifting, send a message to a dedicated cleanup worker instead.
You cannot pass arguments to the callback directly. The function signature is fixed to func(). If you need external data, close over the variables you need. Be careful with loop variables. If you schedule multiple callbacks inside a loop, capture the loop variable explicitly. The compiler rejects this with loop variable i captured by func literal in Go 1.22 and later, but older versions would silently share the final value across all callbacks.
context.AfterFunc does not replace defer. defer runs when the surrounding function returns. AfterFunc runs when the context cancels. They serve different purposes. Mixing them up leads to double cleanup or missed resource releases.
The compiler enforces strict type matching. If you accidentally pass a function that returns a value, you get cannot use func literal (value of type func() error) as func() value in argument. The callback must return nothing. If you forget to import the context package, the compiler rejects the file with undefined: context. Keep your imports clean and let the toolchain catch the rest.
Goroutines are cheap. Channels are not magic.
When to reach for it
Use context.AfterFunc when you need to run a lightweight cleanup task exactly once upon context cancellation. Use defer when you need to guarantee cleanup when a function returns, regardless of how it exits. Use a select loop with time.AfterFunc when you need to manually manage timer lifecycle across multiple goroutines or require complex cancellation routing. Use a dedicated worker channel when the cleanup work is heavy, blocks for I/O, or must run sequentially with other background tasks. Use plain sequential code when you don't need cancellation hooks: the simplest thing that works is usually the right thing.