Future–Promise Pattern #

Imagine ordering food at a restaurant. The cashier hands you a numbered receipt — that is a promise that your food is being prepared. You do not need to stand in front of the kitchen waiting; you can sit down, chat, or read a book. When the food is ready, your name is called — that is the future being fulfilled. This analogy captures the essence of the Future–Promise Pattern: separating when work starts from when its result is used, so execution does not have to stop just because a slow operation exists. In real systems, operations like HTTP requests to external APIs, heavy database queries, or parallel computations are all perfect candidates for this pattern — they take time, and blocking the entire thread just to wait for them is unnecessary waste.

What Is the Future–Promise Pattern? #

The Future–Promise Pattern is a concurrency pattern that separates two distinct roles in producing and consuming asynchronous values.

RoleResponsibility
PromiseThe party that produces the value — runs the task and stores the result
FutureThe party that consumes the value — a placeholder that can be queried when the value is ready

The two are connected through a channel: the promise writes to the channel when the task finishes, and the future reads from the channel when the result is needed. What makes this pattern powerful is that reading (blocking) only happens when the value is truly needed, not when the task starts.

sequenceDiagram
    participant Caller
    participant Promise as Promise (goroutine)
    participant Future as Future (channel)
    participant Consumer

    Caller->>Promise: NewFuture(task)
    Promise-->>Future: goroutine starts, channel created
    Caller->>Consumer: continue other work (non-blocking)
    Note over Promise: task runs in the background
    Promise->>Future: send result to channel
    Consumer->>Future: future.Get() — blocking only here
    Future-->>Consumer: value available, returned

In other languages, this construct exists natively: Java has CompletableFuture, JavaScript has Promise, Scala has Future. Go has no special keyword, but goroutine + channel is the idiomatic equivalent — and it is actually more flexible because you can control every aspect of it.


Why Is Non-Blocking Important? #

To understand the value of this pattern, we should first look at what happens when we do not use it.

// ANTI-PATTERN: sequential execution — all operations wait for each other
func fetchUserData(userID int) UserData {
    profile := fetchProfile(userID)      // 300ms
    orders  := fetchOrders(userID)       // 400ms
    reviews := fetchReviews(userID)      // 200ms
    // Total wait time: 900ms
    return merge(profile, orders, reviews)
}

// CORRECT: run all three in parallel with Futures
func fetchUserData(userID int) UserData {
    fProfile := NewFuture(func() Profile { return fetchProfile(userID) })
    fOrders  := NewFuture(func() []Order { return fetchOrders(userID) })
    fReviews := NewFuture(func() []Review { return fetchReviews(userID) })
    // All three tasks run in parallel — total wait: ~400ms (the slowest task)
    return merge(fProfile.Get(), fOrders.Get(), fReviews.Get())
}

The difference is significant: from 900ms down to ~400ms just by changing the execution pattern. This is the main advantage of Future–Promise — overall latency is determined by the slowest task, not the sum of all tasks.


Basic Implementation #

Go has no built-in Future type, so we build our own using a struct and a channel. This actually gives us full control over its behavior.

package main

import (
	"fmt"
	"time"
)

// Future is a placeholder for a value that will be available later
type Future struct {
	result chan int
}

// NewFuture acts as a Promise — runs the task in the background
// and returns a Future that can be queried later
func NewFuture(task func() int) *Future {
	f := &Future{
		result: make(chan int, 1), // CORRECT: buffered so the goroutine does not leak
	}
	go func() {
		f.result <- task() // task runs in a separate goroutine
	}()
	return f // returned immediately, the task is still running in the background
}

// Get blocks until the value is available, then returns it
func (f *Future) Get() int {
	return <-f.result
}

func main() {
	// Future created — the task starts immediately in the background
	future := NewFuture(func() int {
		fmt.Println("[Task] Starting heavy computation...")
		time.Sleep(2 * time.Second)
		return 42
	})

	// We can do other things while the task runs
	fmt.Println("[Main] Task is running in the background")
	fmt.Println("[Main] Doing other work...")
	time.Sleep(500 * time.Millisecond)
	fmt.Println("[Main] Other work done, now getting the future result")

	// Blocking only happens here, and only if the task is not done yet
	result := future.Get()
	fmt.Printf("[Main] Result received: %d\n", result)
}
Why must the channel be buffered (make(chan int, 1))? If the channel is unbuffered (make(chan int)), the task goroutine will block when trying to write to the channel — and if the caller never calls Get(), that goroutine will leak forever. A buffer of capacity 1 ensures the goroutine can write and finish even if Get() has not been called.

Future with Error Handling #

The implementation above only suits tasks that cannot fail. In the real world, almost every I/O operation can fail — broken connections, timeouts, invalid data. A good Future must carry the error along with its value.

With generics (available since Go 1.18), we can make a type-safe Future for any type:

package main

import (
	"errors"
	"fmt"
	"time"
)

// Result wraps a value and an error in one struct
type Result[T any] struct {
	Value T
	Err   error
}

// Future[T] is a type-safe Future for type T
type Future[T any] struct {
	result chan Result[T]
}

// NewFuture runs the task in the background and returns a Future
func NewFuture[T any](task func() (T, error)) *Future[T] {
	f := &Future[T]{
		result: make(chan Result[T], 1), // CORRECT: buffered
	}
	go func() {
		val, err := task()
		f.result <- Result[T]{Value: val, Err: err}
	}()
	return f
}

// Get blocks until the result is available and returns (value, error)
func (f *Future[T]) Get() (T, error) {
	res := <-f.result
	return res.Value, res.Err
}

// fetchUser simulates a database fetch
func fetchUser(id int) (string, error) {
	time.Sleep(300 * time.Millisecond)
	if id <= 0 {
		return "", errors.New("invalid user ID")
	}
	return fmt.Sprintf("User-%d", id), nil
}

func main() {
	fUser := NewFuture(func() (string, error) {
		return fetchUser(7)
	})

	fInvalid := NewFuture(func() (string, error) {
		return fetchUser(-1) // this one will fail
	})

	// Both futures run in parallel
	user, err := fUser.Get()
	if err != nil {
		fmt.Println("Error:", err)
	} else {
		fmt.Println("User:", user)
	}

	_, err = fInvalid.Get()
	if err != nil {
		fmt.Println("Error (expected):", err)
	}
}

Output:

User: User-7
Error (expected): invalid user ID

Fan-Out: Many Futures in Parallel #

One of the most powerful use cases of Future–Promise is fan-out — running many operations in parallel and collecting their results.

package main

import (
	"fmt"
	"time"
)

type Future[T any] struct {
	result chan T
}

func NewFuture[T any](task func() T) *Future[T] {
	f := &Future[T]{result: make(chan T, 1)}
	go func() { f.result <- task() }()
	return f
}

func (f *Future[T]) Get() T { return <-f.result }

// simulated HTTP request to a different endpoint
func fetchEndpoint(name string, delay time.Duration) string {
	time.Sleep(delay)
	return fmt.Sprintf("response from %s", name)
}

func main() {
	start := time.Now()

	// Fan-out: all requests run in parallel
	futures := []*Future[string]{
		NewFuture(func() string { return fetchEndpoint("service-A", 400*time.Millisecond) }),
		NewFuture(func() string { return fetchEndpoint("service-B", 250*time.Millisecond) }),
		NewFuture(func() string { return fetchEndpoint("service-C", 600*time.Millisecond) }),
		NewFuture(func() string { return fetchEndpoint("service-D", 150*time.Millisecond) }),
	}

	// Fan-in: collect all the results
	results := make([]string, len(futures))
	for i, f := range futures {
		results[i] = f.Get()
	}

	elapsed := time.Since(start)
	for _, r := range results {
		fmt.Println(r)
	}
	fmt.Printf("Total time: %v (without parallel: ~1400ms)\n", elapsed)
}
flowchart TD
    Main([Main Goroutine]) --> FA[Future A\\n400ms]
    Main --> FB[Future B\\n250ms]
    Main --> FC[Future C\\n600ms]
    Main --> FD[Future D\\n150ms]
    FA --> Collect([Collect results])
    FB --> Collect
    FC --> Collect
    FD --> Collect
    Note[Total: ~600ms\\nnot 1400ms]
    Collect --> Note

Cancellation with Context #

For long-running systems or production environments, Futures must be cancellable — for example, if the client’s HTTP request has already timed out before the Future’s result is ready.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type Result[T any] struct {
	Value T
	Err   error
}

type Future[T any] struct {
	result chan Result[T]
}

// NewFutureWithContext creates a Future responsive to cancellation
func NewFutureWithContext[T any](ctx context.Context, task func() (T, error)) *Future[T] {
	f := &Future[T]{result: make(chan Result[T], 1)}
	go func() {
		// Run the task in a separate goroutine so it can be cancelled
		taskResult := make(chan Result[T], 1)
		go func() {
			val, err := task()
			taskResult <- Result[T]{Value: val, Err: err}
		}()

		select {
		case res := <-taskResult:
			f.result <- res // task completed normally
		case <-ctx.Done():
			var zero T
			f.result <- Result[T]{
				Value: zero,
				Err:   errors.New("future cancelled: " + ctx.Err().Error()),
			}
		}
	}()
	return f
}

func (f *Future[T]) Get() (T, error) {
	res := <-f.result
	return res.Value, res.Err
}

func longRunningTask() (string, error) {
	time.Sleep(3 * time.Second) // simulated long task
	return "computation result", nil
}

func main() {
	// Context with a 1-second timeout — the task needs 3 seconds, so it will be cancelled
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	future := NewFutureWithContext(ctx, longRunningTask)

	result, err := future.Get()
	if err != nil {
		fmt.Println("Future failed:", err)
		return
	}
	fmt.Println("Result:", result)
}

Output:

Future failed: future cancelled: context deadline exceeded
stateDiagram-v2
    [*] --> Running : NewFutureWithContext called
    Running --> Completed : task finishes before the deadline
    Running --> Cancelled : ctx.Done() receives a signal
    Completed --> [*] : result sent to channel
    Cancelled --> [*] : error sent to channel

WhenAll: Waiting for All Futures #

Another common pattern is waiting for all futures to finish before continuing — similar to Promise.all() in JavaScript.

package main

import (
	"fmt"
	"time"
)

type Result[T any] struct {
	Value T
	Err   error
	Index int
}

type Future[T any] struct {
	result chan Result[T]
	index  int
}

func NewFuture[T any](index int, task func() (T, error)) *Future[T] {
	f := &Future[T]{result: make(chan Result[T], 1), index: index}
	go func() {
		val, err := task()
		f.result <- Result[T]{Value: val, Err: err, Index: index}
	}()
	return f
}

// WhenAll waits for all futures to finish and collects the results
// The result order follows the order of the given futures
func WhenAll[T any](futures ...*Future[T]) []Result[T] {
	results := make([]Result[T], len(futures))
	for _, f := range futures {
		res := <-f.result
		results[res.Index] = res
	}
	return results
}

func main() {
	futures := []*Future[string]{
		NewFuture(0, func() (string, error) {
			time.Sleep(300 * time.Millisecond)
			return "data from cache", nil
		}),
		NewFuture(1, func() (string, error) {
			time.Sleep(600 * time.Millisecond)
			return "data from database", nil
		}),
		NewFuture(2, func() (string, error) {
			time.Sleep(150 * time.Millisecond)
			return "data from config", nil
		}),
	}

	results := WhenAll(futures...)
	for _, r := range results {
		if r.Err != nil {
			fmt.Printf("Future[%d] error: %v\n", r.Index, r.Err)
		} else {
			fmt.Printf("Future[%d] result: %s\n", r.Index, r.Value)
		}
	}
}

Future–Promise vs Other Concurrency Patterns #

Confusion often arises about when to choose Future–Promise over other patterns. This table helps clarify:

CriterionFuture–PromiseProducer-ConsumerAsync Callback
ResultOne value per futureContinuous data flowCalled when done
BlockingOnly at Get()Consumer blocks at the channelNo blocking
Error handlingCarried with the valueNeeds a separate error channelThrough callback parameters
ComposabilityEasy (WhenAll, WhenAny)ModerateHard (callback hell)
Ideal forOne result from one taskStreaming / task queuesSimple event-driven
flowchart TD
    Q{Need a result\\nfrom an async operation?} -- Yes --> Q2{One result\\nor a stream?}
    Q -- No --> CB[Async Callback\\nor fire-and-forget goroutine]
    Q2 -- One result --> FP[Future–Promise]
    Q2 -- Continuous stream --> PC[Producer-Consumer]
    FP --> Q3{Need\\ncancellation?}
    Q3 -- Yes --> CTX[Future + Context]
    Q3 -- No --> SF[Simple Future]

When Not to Use Future–Promise #

Keep the simple approach if:
  ✓ The operation is very fast (< 1ms) — the goroutine overhead is not worth it
  ✓ The result is used immediately after the operation — direct blocking is simpler
  ✓ The codebase is not familiar with concurrency — abstraction can make debugging harder
  ✓ There is only one async operation — a plain goroutine + channel is enough

Consider Future–Promise if:
  ✗ There are many I/O operations that can run in parallel
  ✗ You need composition (WhenAll, WhenAny, per-future timeouts)
  ✗ The result is needed at a different point from where the task started
  ✗ The system needs granular per-task cancellation

Anti-Patterns to Avoid #

// ✗ Unbuffered channel — goroutine leak if Get() is never called
type LeakyFuture struct {
    result chan int
}
func NewLeakyFuture(task func() int) *LeakyFuture {
    f := &LeakyFuture{result: make(chan int)} // ✗ unbuffered!
    go func() {
        f.result <- task() // will block forever if Get() is not called
    }()
    return f
}

// ✓ Always use a buffered channel with capacity 1
type SafeFuture struct {
    result chan int
}
func NewSafeFuture(task func() int) *SafeFuture {
    f := &SafeFuture{result: make(chan int, 1)} // ✓ buffered
    go func() {
        f.result <- task()
    }()
    return f
}

// ✗ Get() called more than once — blocks forever on the second call
future := NewSafeFuture(func() int { return 42 })
val1 := future.Get() // ✓ succeeds
val2 := future.Get() // ✗ blocks forever — the channel is already empty

// ✓ Store the Get() result in a variable if it is needed more than once
result := future.Get() // ✓ call once
use(result)
use(result) // use the variable, not future.Get() again

// ✗ Launching too many Futures without limits
for i := 0; i < 1_000_000; i++ {
    go func() { /* future */ }() // ✗ can exhaust memory and the scheduler
}

// ✓ Limit with a semaphore or worker pool for large fan-outs
sem := make(chan struct{}, 50) // max 50 concurrent goroutines
for i := 0; i < 1_000_000; i++ {
    sem <- struct{}{}
    go func() {
        defer func() { <-sem }()
        /* future task */
    }()
}

Future–Promise Review Checklist #

FUTURE DESIGN:
  □ The channel inside the Future is always buffered (capacity at least 1)
  □ Get() is called only once per Future — the result is stored in a variable
  □ The Result struct carries the error alongside the value (not just the value)

GOROUTINE LIFECYCLE:
  □ Every Future goroutine has a clear exit path
  □ No goroutine can leak (unbuffered channel without a consumer)
  □ Large fan-outs are bounded with a semaphore or worker pool

CANCELLATION:
  □ Long-running systems use context.Context for timeouts/cancellation
  □ Cancellation is communicated as an error in the Result, not a panic
  □ defer cancel() always exists after context.WithTimeout/WithCancel

ERROR HANDLING:
  □ Errors are always carried alongside the value in the Result struct
  □ The caller always checks the error from Get()
  □ Panics inside the task are recovered and converted into errors

COMPOSITION:
  □ WhenAll is used when all results are needed before continuing
  □ The Get() order considers which task is the slowest (avoid bottlenecks)
  □ No excessive Future use for operations that could be done synchronously

Summary #

  • Future–Promise separates when a task starts from when its result is used — this is what makes execution non-blocking and parallel.
  • In Go, Futures are implemented with goroutine + channel — no special keyword, but this actually gives you full control over the behavior.
  • The channel inside a Future must always be buffered (capacity 1) — an unbuffered channel causes a goroutine leak if Get() is never called.
  • Use generics (Future[T]) for type-safe Futures — available since Go 1.18 and eliminates manual type assertions.
  • The Result struct must carry the error alongside the value — a Future returning only a value is not enough for production systems.
  • Fan-out is the main use case — run N operations in parallel and wait for all of them; latency is determined by the slowest task, not the sum of all tasks.
  • Use context.Context for cancellation — a future running past its deadline must be stoppable cleanly, not left running in the background.
  • Get() may only be called once per Future — the second call blocks forever because the channel is already empty; store the result in a variable.
  • Bound fan-outs with a semaphore — launching too many goroutines at once can burden the scheduler and memory; use a channel semaphore to limit concurrency.
  • Do not over-engineer — if the operation is simple and immediate, a plain channel or direct goroutine is enough; Futures add abstraction the whole team must understand.

← Previous: Producer-Consumer   Next: Async Callback →

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