Async Callback Pattern #
Before goroutines, before channels, before Futures — there were callbacks. This is the oldest and most fundamental pattern for handling asynchronous operations: you hand a function to the caller, and the caller promises to invoke that function when its work is done. You do not wait. You do not poll. You just say “when you are done, call this.” Callbacks are the foundation of almost every event-driven system in existence — from Node.js to GUI frameworks, from network libraries to the operating system itself. In Go, although goroutines and channels are often the primary choice, understanding the Async Callback Pattern is still crucial: many Go libraries expose callback-based APIs, and knowing when a callback is more appropriate than a channel is a skill that separates good engineers from ordinary ones.
What Is the Async Callback Pattern? #
The Async Callback Pattern is a pattern where a function receives another function as a parameter — called a callback — and invokes that callback when the asynchronous operation finishes, whether it succeeds or fails.
The basic flow is always the same:
sequenceDiagram
participant Caller
participant AsyncFunc as Async Function
participant BG as Background (goroutine)
participant CB as Callback
Caller->>AsyncFunc: call with callback
AsyncFunc->>BG: run task in the background
AsyncFunc-->>Caller: return immediately (non-blocking)
Note over Caller: continue other work
BG->>BG: processing...
BG->>CB: call callback(result, err)
Note over CB: continuation logic executesThree characteristics define this pattern:
| Characteristic | Explanation |
|---|---|
| Non-blocking | The async function returns immediately without waiting for the process to finish |
| Inversion of Control | The caller hands control of “what happens next” to the async function |
| Continuation | The continuation logic is packed into the callback, not written after the call |
In Go, callbacks are implemented as function values — first-class citizens that can be stored in variables, passed as arguments, or returned from other functions.
Basic Implementation #
The simplest form of the Async Callback is a function that accepts a callback and runs the task in a separate goroutine.
package main
import (
"fmt"
"time"
)
// Define the callback type explicitly — easier to read and refactor
type Callback func(result string, err error)
// fetchDataAsync runs the fetch in the background and calls the callback when done
func fetchDataAsync(id int, cb Callback) {
go func() {
time.Sleep(2 * time.Second) // simulated I/O
if id <= 0 {
cb("", fmt.Errorf("invalid id: %d", id))
return
}
cb(fmt.Sprintf("data for id %d", id), nil)
}()
// the function returns immediately — the caller is not blocked
}
func main() {
fmt.Println("[Main] Starting data fetch...")
fetchDataAsync(1, func(result string, err error) {
if err != nil {
fmt.Println("[Callback] Error:", err)
return
}
fmt.Println("[Callback] Success:", result)
})
fmt.Println("[Main] Fetch started, continuing other work")
time.Sleep(3 * time.Second) // wait for the callback before the program exits
}
Callback type alias is not just a convenience — it documents the contract between the async function and its caller. Anyone reading this code immediately knows that the callback receives a string and an error, and must handle both.Second, go func() inside fetchDataAsync makes the whole execution non-blocking — the caller returns to the next line before the task finishes. This is the purest form of the Async Callback.
Callbacks with Multiple Events #
In real systems, an asynchronous operation often produces more than one kind of event — progress, success, error, or cancelled. Instead of one callback, we can use a struct containing several callback functions.
package main
import (
"fmt"
"time"
)
// DownloadCallbacks defines every event that can occur
type DownloadCallbacks struct {
OnProgress func(percent int)
OnSuccess func(filePath string)
OnError func(err error)
}
func downloadFileAsync(url string, cbs DownloadCallbacks) {
go func() {
// Simulated download progress
for i := 20; i <= 100; i += 20 {
time.Sleep(300 * time.Millisecond)
if cbs.OnProgress != nil {
cbs.OnProgress(i)
}
}
// Simulated result — an empty URL is treated as an error
if url == "" {
if cbs.OnError != nil {
cbs.OnError(fmt.Errorf("URL must not be empty"))
}
return
}
if cbs.OnSuccess != nil {
cbs.OnSuccess("/tmp/downloaded-file.zip")
}
}()
}
func main() {
downloadFileAsync("https://example.com/file.zip", DownloadCallbacks{
OnProgress: func(percent int) {
fmt.Printf("[Progress] %d%%\n", percent)
},
OnSuccess: func(filePath string) {
fmt.Printf("[Success] File saved to: %s\n", filePath)
},
OnError: func(err error) {
fmt.Printf("[Error] Download failed: %v\n", err)
},
})
time.Sleep(3 * time.Second)
}
nil are simply not called), and adding new events in the future does not break existing signatures.The Callback Hell Problem #
The long history of callbacks carries one bitter lesson repeated in every language: callback hell. This happens when layered async logic forces callbacks to nest inside one another, creating code that is hard to read, test, and maintain.
// ANTI-PATTERN: nested callbacks — the "pyramid of doom"
func processOrder(orderID int) {
validateOrderAsync(orderID, func(valid bool, err error) {
if err != nil { handleError(err); return }
if !valid { handleInvalid(); return }
fetchInventoryAsync(orderID, func(stock int, err error) {
if err != nil { handleError(err); return }
if stock == 0 { handleOutOfStock(); return }
chargePaymentAsync(orderID, func(txID string, err error) {
if err != nil { handleError(err); return }
shipOrderAsync(orderID, txID, func(trackingNum string, err error) {
if err != nil { handleError(err); return }
// Four levels deep — and this is not even the worst case
fmt.Println("Order shipped:", trackingNum)
})
})
})
})
}
// CORRECT: split each stage into a standalone named function
func onValidated(orderID int) func(bool, error) {
return func(valid bool, err error) {
if err != nil { handleError(err); return }
if !valid { handleInvalid(); return }
fetchInventoryAsync(orderID, onInventoryFetched(orderID))
}
}
func onInventoryFetched(orderID int) func(int, error) {
return func(stock int, err error) {
if err != nil { handleError(err); return }
if stock == 0 { handleOutOfStock(); return }
chargePaymentAsync(orderID, onPaymentCharged(orderID))
}
}
func onPaymentCharged(orderID int) func(string, error) {
return func(txID string, err error) {
if err != nil { handleError(err); return }
shipOrderAsync(orderID, txID, onOrderShipped)
}
}
func onOrderShipped(trackingNum string, err error) {
if err != nil { handleError(err); return }
fmt.Println("Order shipped:", trackingNum)
}
func processOrder(orderID int) {
validateOrderAsync(orderID, onValidated(orderID)) // ✓ flat, easy to read
}
flowchart TD
subgraph BAD["❌ Callback Hell"]
A1[validateOrder] --> B1[fetchInventory\\n inside callback A]
B1 --> C1[chargePayment\\n inside callback B]
C1 --> D1[shipOrder\\n inside callback C]
end
subgraph GOOD["✓ Named Callbacks"]
A2[validateOrder] --> B2[onValidated]
B2 --> C2[onInventoryFetched]
C2 --> D2[onPaymentCharged]
D2 --> E2[onOrderShipped]
endCallbacks with Cancellation #
For long-running operations, a callback without a cancellation mechanism is a recipe for goroutine leaks. The solution is integrating context.Context into the async function.
package main
import (
"context"
"fmt"
"time"
)
type ProcessCallback func(result string, err error)
// processAsync accepts a context — cancellable from the outside
func processAsync(ctx context.Context, payload string, cb ProcessCallback) {
go func() {
// Simulated work that can be cancelled mid-way
select {
case <-time.After(3 * time.Second):
// Work completed normally
cb(fmt.Sprintf("processed: %s", payload), nil)
case <-ctx.Done():
// Context cancelled before completion
cb("", fmt.Errorf("operation cancelled: %w", ctx.Err()))
}
}()
}
func main() {
// Create a context that will time out after 1 second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
done := make(chan struct{})
processAsync(ctx, "order-123", func(result string, err error) {
defer close(done)
if err != nil {
fmt.Println("[Callback] Error:", err)
return
}
fmt.Println("[Callback] Result:", result)
})
<-done // wait for the callback to finish
fmt.Println("[Main] Done")
}
select with ctx.Done() and the main operation ensures the goroutine always has an exit path — whether it finishes normally or is cancelled. No goroutine is left waiting forever.stateDiagram-v2
[*] --> Running : goroutine starts
Running --> Completed : operation finishes before the deadline
Running --> Cancelled : ctx.Done() receives a signal
Completed --> [*] : callback(result, nil) called
Cancelled --> [*] : callback("", err) calledCombining Multiple Async Callbacks #
When several async operations need to run in parallel and their results collected, combining callbacks with sync.WaitGroup and a mutex is a solid pattern.
package main
import (
"fmt"
"sync"
"time"
)
type StringCallback func(result string, err error)
func fetchAsync(name string, delay time.Duration, cb StringCallback) {
go func() {
time.Sleep(delay)
cb(fmt.Sprintf("data from %s", name), nil)
}()
}
func main() {
var (
mu sync.Mutex
results []string
wg sync.WaitGroup
)
sources := []struct {
name string
delay time.Duration
}{
{"cache", 100 * time.Millisecond},
{"database", 400 * time.Millisecond},
{"external-api", 250 * time.Millisecond},
}
for _, src := range sources {
wg.Add(1)
fetchAsync(src.name, src.delay, func(result string, err error) {
defer wg.Done()
if err != nil {
fmt.Println("Error:", err)
return
}
mu.Lock()
results = append(results, result) // ✓ lock when accessing shared state
mu.Unlock()
})
}
wg.Wait() // wait for all callbacks to finish
fmt.Println("All results collected:")
for _, r := range results {
fmt.Println(" -", r)
}
}
Callbacks run on a different goroutine. This means the callback runs concurrently with other goroutines. Any access to shared state inside the callback — like the results slice above — must be protected with a mutex or another synchronization mechanism. Ignoring this causes data races that often only appear in production, not during development.Async Callback vs Channel vs Future #
These three approaches are often confused with each other. Understanding the trade-offs of each helps you choose the right one for each situation.
| Aspect | Async Callback | Channel | Future–Promise |
|---|---|---|---|
| Model | Event-based | Message-based | Result-based |
| Blocking | None | At send/receive | Only at Get() |
| Error handling | Callback parameter | Separate error channel | Carried with the value |
| Composability | Low (callback hell) | Moderate | High (WhenAll, etc.) |
| Idiomatic Go | No (but valid) | Yes | Yes (with generics) |
| Best for | Simple async, event APIs | Orchestration, pipelines | One result from one task |
| Goroutine management | Manual | Semi-automatic | Semi-automatic |
flowchart TD
Q{Need a result\\nat a specific point?} -- Yes --> Q2{One result\\nor many?}
Q -- No --> Q3{Many\\nevent types?}
Q2 -- One --> FP[Future–Promise]
Q2 -- Many / streaming --> CH[Channel]
Q3 -- Yes --> CB[Struct Callback\\nmulti-event]
Q3 -- No --> Q4{Many parallel\\noperations?}
Q4 -- Yes --> CH
Q4 -- No --> CB2[Simple Callback]When Not to Use Async Callback #
Keep using Async Callback if:
✓ Integrating an external library that exposes a callback-based API
✓ The operation produces many different events (progress, success, error)
✓ No need to wait for the result at a specific point
✓ The team is familiar with the pattern and the codebase uses it consistently
Consider Channel or Future if:
✗ The async logic is nested more than two levels — a sign callback hell is coming
✗ You need chaining or composition of many async operations
✗ Error handling becomes complicated with callbacks
✗ You need easy, expressive cancellation
✗ This is new Go code without external library constraints
Anti-Patterns to Avoid #
// ✗ Callback called more than once — violates the contract
func badAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // called once
if err != nil {
cb("", err) // ✗ called again — the caller is not ready for this
}
}()
}
// ✓ Callback called exactly once, whatever the condition
func goodAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ one call, done
}()
}
// ✗ Callback without an error — no way to report failure
func noErrorCallback(cb func(string)) {
go func() {
result, err := doWork()
if err != nil {
// error dropped — the caller does not know anything went wrong
return
}
cb(result)
}()
}
// ✓ Callback always carries the error as the second parameter
func withErrorCallback(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ the caller is responsible for handling the error
}()
}
// ✗ Goroutine leak — no stop mechanism
func leakyAsync(cb func(string, error)) {
go func() {
time.Sleep(24 * time.Hour) // what if the caller no longer cares?
cb("done", nil) // this goroutine lives forever if the caller already exited
}()
}
// ✓ Use a context for lifecycle management
func safeAsync(ctx context.Context, cb func(string, error)) {
go func() {
select {
case <-time.After(24 * time.Hour):
cb("done", nil)
case <-ctx.Done():
cb("", ctx.Err()) // ✓ the goroutine can stop when asked
}
}()
}
// ✗ Shared state access without synchronization — data race
var sharedResults []string
func unsafeCallback(result string, err error) {
sharedResults = append(sharedResults, result) // ✗ race condition
}
// ✓ Protect shared state with a mutex
var (
mu sync.Mutex
protectedResults []string
)
func safeCallback(result string, err error) {
mu.Lock()
defer mu.Unlock()
protectedResults = append(protectedResults, result) // ✓ safe from race conditions
}
#
// ✗ Callback called more than once — violates the contract
func badAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // called once
if err != nil {
cb("", err) // ✗ called again — the caller is not ready for this
}
}()
}
// ✓ Callback called exactly once, whatever the condition
func goodAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ one call, done
}()
}
// ✗ Callback without an error — no way to report failure
func noErrorCallback(cb func(string)) {
go func() {
result, err := doWork()
if err != nil {
// error dropped — the caller does not know anything went wrong
return
}
cb(result)
}()
}
// ✓ Callback always carries the error as the second parameter
func withErrorCallback(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ the caller is responsible for handling the error
}()
}
// ✗ Goroutine leak — no stop mechanism
func leakyAsync(cb func(string, error)) {
go func() {
time.Sleep(24 * time.Hour) // what if the caller no longer cares?
cb("done", nil) // this goroutine lives forever if the caller already exited
}()
}
// ✓ Use a context for lifecycle management
func safeAsync(ctx context.Context, cb func(string, error)) {
go func() {
select {
case <-time.After(24 * time.Hour):
cb("done", nil)
case <-ctx.Done():
cb("", ctx.Err()) // ✓ the goroutine can stop when asked
}
}()
}
// ✗ Shared state access without synchronization — data race
var sharedResults []string
func unsafeCallback(result string, err error) {
sharedResults = append(sharedResults, result) // ✗ race condition
}
// ✓ Protect shared state with a mutex
var (
mu sync.Mutex
protectedResults []string
)
func safeCallback(result string, err error) {
mu.Lock()
defer mu.Unlock()
protectedResults = append(protectedResults, result) // ✓ safe from race conditions
}
Async Callback Review Checklist #
CALLBACK DESIGN:
□ The callback type is defined as an explicit type alias
□ The callback always carries the error as a parameter (func(T, error))
□ The callback is called exactly once per async execution
GOROUTINE LIFECYCLE:
□ The async goroutine has a clear exit path (context or done signal)
□ No goroutine can leak due to a missing stop mechanism
□ Callbacks managing multiple async operations use a WaitGroup
CONCURRENCY:
□ Shared state access inside callbacks is protected with a mutex
□ Callbacks are not assumed to run on the same goroutine as the caller
□ Race conditions are verified with go test -race
COMPLEXITY:
□ Callbacks are not nested more than two levels
□ Long callbacks are split into named functions
□ Migration to channels/Futures is considered as the logic grows
DOCUMENTATION:
□ The async function documents when the callback is called
□ The async function documents whether the callback runs on a separate goroutine
□ The "call once" contract is documented explicitly
Summary #
- The Async Callback is the most fundamental concurrency pattern — almost every modern async abstraction (Future, Promise, async/await) is built on top of it.
- Callbacks accept control over “what happens next” — this is Inversion of Control: the caller hands the continuation logic to the async function.
- Always define the callback type as a type alias — this documents the contract and makes the code easier to refactor.
- Callbacks must always carry the error —
func(T, error)is the standard signature; a callback without an error cannot report failure.- Callbacks are called exactly once — calling the callback more than once violates the contract and can cause hard-to-detect bugs.
- Callback hell happens when callbacks nest — the solution is splitting each stage into separate named functions that are flat and independently testable.
- Use a struct of callbacks for multiple events — separate OnProgress, OnSuccess, OnError callbacks are more expressive than one callback with many conditions.
- Callbacks run on a different goroutine — shared state access inside callbacks must be protected with a mutex to prevent data races.
- Integrate a context for cancellation —
selectwithctx.Done()ensures the async goroutine always has an exit path.- Know its limits — for nested operations or those needing composition, channels or Futures are more idiomatic in Go; callbacks shine when integrating external APIs or simple events.