Cancellation often means more than telling a goroutine to stop. A blocked operation may need to be interrupted, a temporary resource may need cleanup, or some state may need to be released as soon as a request deadline expires.

A common approach is to start another goroutine that waits on ctx.Done(). That works, but it adds lifecycle code every time you need cancellation-triggered behavior. Since Go 1.21, the standard context package provides context.AfterFunc for this job.

context.AfterFunc associates a function with a context. When the context is canceled or its deadline expires, Go starts that function in its own goroutine. The subtle part is not registering the function. It is understanding the race between cancellation and the returned stop function.

Register work that should happen after cancellation

The basic shape is small:

stop := context.AfterFunc(ctx, func() {
    cleanup()
})

If ctx later becomes done, the callback is started asynchronously. If the context is already done when AfterFunc is called, the callback is started immediately in its own goroutine.

Multiple calls are independent. Registering a second callback does not replace the first one.

This makes AfterFunc useful when a resource or operation has an action that should be tied directly to a context lifetime rather than to the lexical lifetime of the current function.

The returned function stops the association

AfterFunc returns a function with this shape:

stop := context.AfterFunc(ctx, callback)
stopped := stop()

A true result means stop prevented the callback from running. A false result is intentionally less specific: either the callback has already been started because the context became done, or the association had already been stopped.

That distinction matters. This is unsafe reasoning:

if !stop() {
    // Wrong assumption: callback must have finished.
}

The callback may only have started. The stop function does not wait for it to complete.

If your code must know that the callback is finished, add explicit synchronization.

Coordinate explicitly when completion matters

A channel is often enough:

package main

import (
    "context"
    "fmt"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    done := make(chan struct{})

    stop := context.AfterFunc(ctx, func() {
        defer close(done)
        fmt.Println("cleanup")
    })

    cancel()

    if !stop() {
        <-done
    }
}

There are two possible paths.

If stop() returns true, it prevented the callback, so waiting on done would deadlock because the callback will never close it. If stop() returns false, this example knows cancellation won the race and waits until the callback finishes.

This pattern is useful when the caller needs a clean handoff before it can safely reuse or destroy state touched by the callback.

Make the callback safe to race with normal completion

Cancellation and successful completion can happen very close together. Design the callback under the assumption that another goroutine may be finishing the operation at the same time.

For example, suppose a context-triggered callback closes a connection while the normal path also closes it. Whether that is safe depends on the resource’s API. If cleanup is not naturally safe to call more than once, guard it explicitly:

var once sync.Once

cleanup := func() {
    once.Do(func() {
        releaseResource()
    })
}

stop := context.AfterFunc(ctx, cleanup)
defer func() {
    stop()
    cleanup()
}()

Here both paths may attempt cleanup, but sync.Once gives the cleanup action one owner at execution time.

Do not add sync.Once mechanically. Some APIs already define repeated close or cancellation operations safely, while others return meaningful errors that you should preserve. The important step is to identify the ownership rule instead of assuming cancellation cannot overlap normal completion.

Use AfterFunc to interrupt operations that lack context support carefully

Sometimes an API blocks but does not accept a context.Context. If the API provides a separate operation that safely interrupts the block, AfterFunc can connect cancellation to it.

Conceptually:

stop := context.AfterFunc(ctx, func() {
    resource.Interrupt()
})

er := resource.BlockingOperation()

if !stop() {
    // The interrupt callback may be running concurrently.
    // Coordinate here if the resource requires it.
}

This does not magically make every blocking API context-aware. The interrupt method itself must be documented as safe for the required concurrent use, and you still need to decide what happens if the operation finishes at the same moment cancellation occurs.

Prefer APIs that accept a context directly when they exist. AfterFunc is most useful at integration boundaries where cancellation must trigger a separate action.

Do not treat AfterFunc as a general defer replacement

defer and AfterFunc have different triggers.

A deferred function runs when the surrounding function returns. An AfterFunc callback runs when its context becomes done. If a function succeeds while its context remains active, an AfterFunc callback may never run unless something later cancels that context.

For ordinary lexical cleanup, keep using defer:

f, err := os.Open(name)
if err != nil {
    return err
}
defer f.Close()

Use AfterFunc when cancellation itself is the event that should initiate an action.

In some designs both are appropriate: AfterFunc handles cancellation promptly, while a deferred path stops the association and performs normal cleanup.

Keep callbacks short and cancellation-focused

Because the callback starts in its own goroutine, it is tempting to put arbitrary background work there. That usually makes shutdown behavior harder to reason about.

A good callback typically performs a bounded action such as interrupting an operation, closing a cancellation-specific resource, or signaling another component. If the callback starts a long workflow, you need another lifecycle mechanism to wait for, cancel, and observe that workflow.

Also remember that the callback should not depend on the canceled context for successful work. Once it runs, ctx.Done() is already closed and operations derived from that same context will observe cancellation.

If cleanup itself needs a timeout or independent context, create that policy deliberately rather than accidentally reusing the canceled one.

Test both sides of the race

Cancellation code deserves tests for at least two outcomes: normal completion prevents the callback, and cancellation starts it.

The first case can be deterministic:

func TestStopPreventsCallback(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    called := make(chan struct{}, 1)
    stop := context.AfterFunc(ctx, func() {
        called <- struct{}{}
    })

    if !stop() {
        t.Fatal("expected callback to be stopped")
    }

    cancel()

    select {
    case <-called:
        t.Fatal("callback ran after stop succeeded")
    default:
    }
}

For the cancellation path, synchronize on the callback instead of sleeping:

func TestCancellationRunsCallback(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    called := make(chan struct{})

    context.AfterFunc(ctx, func() {
        close(called)
    })

    cancel()

    select {
    case <-called:
    case <-time.After(time.Second):
        t.Fatal("callback did not run")
    }
}

Avoid tests that assume the callback runs before the next statement after cancel(). The API promises asynchronous execution, not immediate completion.

Remember the Go version boundary

context.AfterFunc was added to the standard library in Go 1.21. A module whose supported toolchain includes older Go releases cannot use it without changing that compatibility requirement or providing another implementation.

For older code, a goroutine selecting or receiving from ctx.Done() remains a straightforward alternative:

go func() {
    <-ctx.Done()
    cleanup()
}()

That alternative can be correct, but the program owns the goroutine lifecycle and any mechanism needed to prevent or coordinate the cleanup. AfterFunc packages the context association and stop race into a standard API.

The practical takeaway

context.AfterFunc is a small tool for a specific boundary: run an action asynchronously when a context becomes done, while retaining a way to prevent that action if normal completion wins first.

The returned stop function is the key to using it correctly. true means the callback was prevented. false does not mean the callback has completed, and stop never waits for it. When callback completion matters, synchronize explicitly. When cleanup can race with the normal path, define ownership or idempotence deliberately.

Used with those rules, AfterFunc can replace repetitive context-watcher goroutines and make cancellation-triggered cleanup easier to connect to the lifetime it actually belongs to.