context.AfterFunc attaches a callback to context cancellation without adding a goroutine that waits only on ctx.Done(). When the context becomes done, the callback starts in its own goroutine. The small API hides a concurrency boundary that matters when the callback mutates shared state, interrupts blocking I/O, or competes with normal completion.

The function arrived in Go 1.21 and returns a stop function. That return value is not a general cancellation handle for the callback. It controls the association between the context and the callback, with precise behavior once cancellation and callback startup begin to race.

Cancellation schedules work rather than running it inline

The signature is compact:

func AfterFunc(ctx context.Context, f func()) (stop func() bool)

If ctx is canceled later, f starts in a separate goroutine. If ctx is already canceled when AfterFunc is called, f is still started in its own goroutine.

That means code after cancel() cannot assume the callback has completed:

ctx, cancel := context.WithCancel(context.Background())

done := make(chan struct{})

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

cancel()

<-done

The receive on done is the synchronization point. Without it, cancellation only establishes that the context is done; it does not establish that the callback has finished.

Multiple callbacks registered on the same context are independent. Their execution order is not an ordering mechanism, so related callbacks need their own synchronization if one depends on another.

The stop result describes a race outcome

Calling the returned function attempts to detach the callback from the context:

stop := context.AfterFunc(ctx, cleanup)

if stop() {
    // cleanup was prevented from starting through this registration.
}

A true result means the call prevented f from being started by AfterFunc. A false result has two possible meanings: the callback has already been started because the context became done, or the association was already stopped by an earlier call.

This distinction prevents a common incorrect assumption. stop() returning false does not mean the callback has completed. It may still be running.

When normal completion and cancellation can happen concurrently, code often needs explicit completion signaling:

callbackDone := make(chan struct{})

stop := context.AfterFunc(ctx, func() {
    defer close(callbackDone)
    release()
})

if !stop() {
    <-callbackDone
}

This shape is appropriate only when release is guaranteed to finish and callbackDone can be closed exactly once. The channel coordinates callback completion; stop alone does not.

Interrupting blocking operations needs reversible state

One useful application is converting context cancellation into an operation-specific interruption. A network read is a representative case because a deadline can force a blocked Read to return.

func readWithContext(ctx context.Context, conn net.Conn, buf []byte) (int, error) {
    callbackDone := make(chan struct{})

    stop := context.AfterFunc(ctx, func() {
        defer close(callbackDone)
        _ = conn.SetReadDeadline(time.Now())
    })

    n, err := conn.Read(buf)

    if !stop() {
        <-callbackDone
        _ = conn.SetReadDeadline(time.Time{})
        return n, ctx.Err()
    }

    return n, err
}

The callback changes connection state to interrupt the read. If the callback started, the function waits for that state change to finish and then clears the deadline before returning.

The reset is part of the concurrency contract. A connection can outlive one operation, so leaving the forced deadline installed can affect a later read that has no relation to the canceled context.

The same principle applies to callbacks that close temporary resources, signal condition variables, or alter other reusable objects: cancellation-induced state changes need a defined ownership and restoration policy.

Callback code must tolerate concurrent execution

AfterFunc does not serialize f with the function that registered it. The callback can run at the same time as normal cleanup, return-path bookkeeping, or another cancellation callback.

A callback that closes a resource may therefore overlap with code that also closes it. Some types permit repeated close operations with defined results; other cleanup functions do not. Shared memory still requires ordinary synchronization.

For a cleanup action that must execute once across both paths, sync.Once can make the ownership explicit:

var once sync.Once

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

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

Here both cancellation and normal return can request cleanup, but the protected action executes once. This pattern addresses duplicate execution; it does not make releaseResource safe to race with unrelated users of the resource.

A callback should stay bounded

Cancellation commonly indicates that an operation should release resources and stop promptly. A callback that blocks indefinitely can create a goroutine that remains after the canceled operation has otherwise ended.

AfterFunc does not wait for callbacks, impose a deadline on them, or recover resources held by callback code. If a callback performs I/O or waits for another component, that work needs its own bounded behavior.

The callback also should not assume that the canceled context can drive new work. Passing the same canceled context into an operation that checks Done() usually causes that operation to terminate immediately. Cleanup that genuinely requires a separate lifetime needs a context chosen for that lifetime rather than accidental reuse of the canceled one.

Registration is not a substitute for structured ownership

A goroutine selecting on ctx.Done() remains appropriate when cancellation is one event inside a longer-running state machine. AfterFunc fits a narrower shape: one action should be scheduled when a context becomes done, and the caller may need to detach that action if normal completion wins first.

That narrower contract keeps cancellation integration close to the resource it affects. The key boundary is the callback startup race. Once stop() can return false, the callback may already be concurrent with the caller, and any requirement about completion, ordering, or shared state has to be expressed with synchronization outside AfterFunc.