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 executes

Three characteristics define this pattern:

CharacteristicExplanation
Non-blockingThe async function returns immediately without waiting for the process to finish
Inversion of ControlThe caller hands control of “what happens next” to the async function
ContinuationThe 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
}
There are several important details here that are often overlooked. First, the 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)
}
This struct-callback pattern gives flexibility: the caller can only fill in the callbacks it needs (fields left 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
}
The difference is drastic: the first version is a pyramid of code that strains the eyes and makes the logic hard to follow, while the second is a flat sequence of calls where each stage is easy to test independently.

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]
    end

Callbacks 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")
}
Using 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) called

Combining 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.

AspectAsync CallbackChannelFuture–Promise
ModelEvent-basedMessage-basedResult-based
BlockingNoneAt send/receiveOnly at Get()
Error handlingCallback parameterSeparate error channelCarried with the value
ComposabilityLow (callback hell)ModerateHigh (WhenAll, etc.)
Idiomatic GoNo (but valid)YesYes (with generics)
Best forSimple async, event APIsOrchestration, pipelinesOne result from one task
Goroutine managementManualSemi-automaticSemi-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
}
#

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 errorfunc(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 cancellationselect with ctx.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.

← Previous: Future-Promise   Next: Fork–Join →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact