Master Go's Context Package

Build a concurrent data fetcher using Go's context primitives.

Introduction

30 Second Summary

Imagine sending five couriers to pick up packages from different warehouses. One warehouse is impossibly slow. You want to recall that courier without waiting forever or disrupting the others who already finished.

In this project, you will build a concurrent Go program that fetches data from multiple simulated sources with varying speeds. You will progressively layer in context primitives to automatically cancel slow operations, handle Ctrl+C gracefully, and trace requests across concurrent goroutines.

What You'll Build

Run your finished tool in the terminal: four data sources race concurrently, the slow one gets cut off at the deadline, and every log line carries a request ID proving the context tree connected them all.

By the end of this project, you'll have:

  • A working timeout that automatically cancels slow goroutines after a deadline, so your program finishes in 3 seconds instead of 10.
  • Graceful shutdown where pressing Ctrl+C propagates cancellation through every in-flight goroutine, printing cleanup messages instead of dying silently.
  • Request tracing where every concurrent log line carries the same request ID, passed through the context tree without extra function parameters.
  • Secret Mission: a first-result-wins race pattern that returns only the fastest source and cancels everything else immediately.

Are there any prerequisites?

This project assumes you are familiar with Go goroutines and channels. If you have written a basic concurrent Go program before, you are ready to start.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

You're going to build a concurrent multi-source data fetcher in Go that progressively layers in context primitives to solve real concurrency problems you'll hit along the way.

Set Up the Project

To experiment with Go's context package, you need a project to work in. A Go module gives you a self-contained workspace where the compiler can find and build your code.

In this step, you'll create a project folder, initialize a Go module, and write a minimal program to confirm everything runs.

In this step, get ready to:
  • Create a project directory and initialize a Go module.
  • Write and run a minimal Go program.
Create the project directory and Go module
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick), or by clicking Terminal in the menu bar and selecting New Terminal.
  • Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir context-fetcher
cd context-fetcher
  • Initialize a Go module by running:
go mod init context-fetcher
  • Confirm the module was created by running:
cat go.mod

You should see two lines: module context-fetcher and go 1.26.4.

Don't see go.mod?

Make sure you ran go mod init context-fetcher from inside the context-fetcher directory. Run pwd to confirm your current location ends with /context-fetcher.

If cat is not recognized (rare on Windows cmd), try type go.mod instead.

help me troubleshoot go mod init not creating go.mod

  • Open this folder in VS Code's file explorer by clicking File in the menu bar, then selecting Open Folder.
  • Navigate to your Desktop, select the context-fetcher folder, and click Open.

What is a Go module?

A Go module is a collection of Go source files grouped by a go.mod file at the root. The module name (context-fetcher) tells the Go toolchain how to identify your project when importing packages.

Since this project uses only the standard library, go.mod is all you need. No external dependencies to download.

Write and run your first Go file
  • In the VS Code Explorer sidebar, click the New File icon next to the folder name context-fetcher.
  • Name the file main.go and press Enter.
  • Paste the following code into main.go:
package main

import "fmt"

func main() {
	// Print a greeting to confirm the project runs
	fmt.Println("Hello, context!")
}

What does this code do?

  • package main declares this file as the entry point for a Go executable.
  • import "fmt" brings in the standard library's formatted I/O package.
  • func main() is the function Go calls when you run the program.
  • Save main.go by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run this, what do you expect to see in the terminal?

  • Run your program by entering this command in the terminal:
go run main.go

You should see Hello, context! printed on its own line. Your Go module is alive and running.

Seeing an error instead?

If you see cannot find package, make sure your terminal is inside the context-fetcher folder (run pwd to check). The go.mod file must be in the same directory as main.go.

If you see a syntax error, check that your quotes are straight " characters, not curly quotes. Also confirm the file is saved.

help me fix my go run main.go error

✔️ Awesome, I've got everything!

Great. Double check you've saved main.go and that go run main.go prints Hello, context!.

ⓧ I'd like to double check the full code

Here is the complete main.go file:

package main

import "fmt"

func main() {
	fmt.Println("Hello, context!")
}

And your go.mod (auto-generated by go mod init):

module context-fetcher

go 1.26.4

Your Go module is ready and confirmed working. Next up, you'll build a concurrent fetcher that launches multiple goroutines. Without context, you'll discover first-hand how a slow source can hold your entire program hostage.

Build the Naive Fetcher

Your Go module is set up and running. Now it's time to build something that reveals why Go's context package exists.

You'll create a fetcher that pulls data from four simulated sources concurrently. One source is deliberately slow. Without any cancellation mechanism, you're stuck waiting for the slowest source to finish before the program can exit.

In this step, get ready to:
  • Define a Source struct and a fetchFromSource function.
  • Launch all sources concurrently with goroutines and a WaitGroup.
  • Experience the 10-second hang caused by the slow source.
Define the Source struct and fetch function

Each simulated data source needs a name and a response time. A dedicated function will simulate "fetching" from that source by sleeping for the specified duration.

  • In main.go, replace the entire file contents with the following code:
package main

import (
	"fmt"
	"time"
)

// Source represents a data source with a simulated response time
type Source struct {
	Name    string
	Latency time.Duration
}

// fetchFromSource simulates a network call by sleeping for the source's latency
func fetchFromSource(src Source) string {
	time.Sleep(src.Latency)
	return fmt.Sprintf("data from %s", src.Name)
}

func main() {
}

What does this code do?

  • Source is a struct holding a name and a simulated network latency as a time.Duration.
  • fetchFromSource simulates a slow network call using time.Sleep, then returns a result string.
  • The empty func main() keeps the file compilable while you build the next piece.
  • Save main.go.
  • Confirm it compiles by running:
go build .
  • No output means success. If you see nothing, the file compiled cleanly.

Seeing a compile error?

  • Check that "time" appears in your import block. The Source struct uses time.Duration and fetchFromSource uses time.Sleep.
  • Make sure func main() {} is present at the bottom of the file. Go requires a main function to compile.

Why won't my Go file compile?

Launch all fetches concurrently

Now you'll write the main function that launches all four sources at once using goroutines. A WaitGroup tracks completion, and a Mutex protects the shared results slice from race conditions.

  • In main.go, update your import block to add "sync" so it reads:
import (
	"fmt"
	"sync"
	"time"
)
  • Replace the empty func main() {} with the following code. This first block defines the sources, sets up concurrency primitives, and launches the goroutines:
func main() {
	sources := []Source{
		{Name: "Source A", Latency: 500 * time.Millisecond},
		{Name: "Source B", Latency: 2 * time.Second},
		{Name: "Source C", Latency: 10 * time.Second},
		{Name: "Source D", Latency: 800 * time.Millisecond},
	}

	// Mutex protects the shared results slice from concurrent writes
	var mu sync.Mutex
	var results []string
	var wg sync.WaitGroup

	start := time.Now()

	// Launch each fetch in its own goroutine
	for _, src := range sources {
		wg.Add(1)
		go func(s Source) {
			defer wg.Done()
			result := fetchFromSource(s)
			mu.Lock()
			results = append(results, result)
			mu.Unlock()
		}(src)
	}
  • Continue with this code immediately after the closing } of the for loop, still inside the main function:
	// Block until all goroutines finish
	wg.Wait()
	elapsed := time.Since(start)

	fmt.Printf("\n--- Results (took %v) ---\n", elapsed)
	for _, r := range results {
		fmt.Printf("  - %s\n", r)
	}
}

What does this code do?

  • The sources slice defines four sources. Source C has a 10-second latency, making it the bottleneck.
  • sync.WaitGroup tracks when all goroutines finish. Each goroutine calls wg.Done() when complete.
  • sync.Mutex protects the shared results slice. Without it, multiple goroutines appending at the same time would cause a data race.
  • wg.Wait() blocks until every goroutine has finished. Only then does the program print results and elapsed time.
  • Save main.go.

Seeing an "imported and not used" error?

  • Make sure you added "sync" to your import block. The sync.WaitGroup and sync.Mutex types come from this package.
  • If you see sync imported and not used, double-check that your main function body is filled in, not still empty.

Help me fix my Go import errors.

Run the fetcher and feel the problem

Before you run this, think about how long the program will take. You have four sources running concurrently. The fastest takes 500ms. The slowest takes 10 seconds. What do you expect the total elapsed time to be?

  • Run the program:
go run main.go

The terminal hangs for about 10 seconds, then prints all four results. The elapsed time shows roughly 10 seconds.

Why does it take 10 seconds?

Even though the sources run concurrently, wg.Wait() blocks until ALL goroutines finish. Source C takes 10 seconds, so the entire program waits for it.

There is no way to cancel Source C or bail out early. You cannot stop it without killing the entire process. This is the exact problem that Go's context package solves.

✔️ Awesome, I've got everything!

Great. Double-check you've saved main.go and your program runs for about 10 seconds before printing results.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll up and confirm each one is present:

  • Package declaration and import block with "fmt", "sync", and "time".
  • Source struct with Name (string) and Latency (time.Duration) fields.
  • fetchFromSource function that takes a Source, sleeps for its latency, and returns a formatted string.
  • main function with: sources slice (4 sources), sync.Mutex, sync.WaitGroup, goroutine loop, and result printing.

You've felt the pain firsthand. Source C holds your entire program hostage for 10 seconds, and there is nothing you can do about it. In the next step, you'll reach for context.WithTimeout to give each goroutine a deadline and kill the slow ones automatically.

Add context.WithTimeout

In the previous step, you watched Source C block your entire program for 10 seconds.

The problem is clear: there is no way to tell a goroutine to stop. context.WithTimeout solves this by attaching a deadline to each goroutine. If a source does not respond before the deadline, the context cancels and the goroutine exits cleanly.

In this step, get ready to:
  • Refactor fetchFromSource to accept and respond to a context.
  • Create a fetchAll function that gives each source a 3-second timeout.
  • Drop total program time from ~10 seconds to ~3 seconds.
Refactor fetchFromSource to accept context

Go's context package provides a standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries. Your fetchFromSource function needs to accept a context.Context as its first parameter so it can respond when that context is cancelled.

  • In main.go, find the import block at the top of the file and add "context" so it looks like this:
import (
	"context"
	"fmt"
	"sync"
	"time"
)
  • Now replace your existing fetchFromSource function with this version that uses a select statement:
func fetchFromSource(ctx context.Context, src Source) (string, error) {
	fmt.Printf("fetching from %s (expected latency: %v)\n", src.Name, src.Latency)

	select {
	case <-time.After(src.Latency):
		result := fmt.Sprintf("data from %s", src.Name)
		fmt.Printf("%s completed\n", src.Name)
		return result, nil
	case <-ctx.Done():
		fmt.Printf("%s cancelled: %v\n", src.Name, ctx.Err())
		return "", ctx.Err()
	}
}

What does this code do?

  • ctx context.Context is now the first parameter, following Go's convention of always passing context as the first argument.
  • The function now returns (string, error) because a cancelled fetch is an error case, not a success.
  • select blocks until one of two channels fires: <-time.After(src.Latency) means the source responded in time, and <-ctx.Done() means the context's deadline was reached.
  • ctx.Err() returns context deadline exceeded when a timeout fires, giving you a clear reason for the cancellation.
Create the fetchAll function with per-source timeout

Each source gets its own timeout context. This means a slow source hitting its deadline does not affect sources that have already completed.

  • Add a new fetchAll function below fetchFromSource by pasting this code:
func fetchAll(sources []Source, timeout time.Duration) []string {
	var mu sync.Mutex
	var results []string
	var wg sync.WaitGroup

	for _, src := range sources {
		wg.Add(1)
		go func(s Source) {
			defer wg.Done()

			srcCtx, cancel := context.WithTimeout(context.Background(), timeout)
			defer cancel()

			result, err := fetchFromSource(srcCtx, s)
			if err != nil {
				return
			}

			mu.Lock()
			results = append(results, result)
			mu.Unlock()
		}(src)
	}

	wg.Wait()
	return results
}

What does this code do?

  • context.WithTimeout(context.Background(), timeout) creates a new context that automatically cancels after the given duration.
  • defer cancel() ensures the context's resources are released when the goroutine finishes, even if the timeout was never reached. Failing to call cancel() leaks internal timers.
  • If fetchFromSource returns an error (from cancellation), the goroutine skips appending to results and exits.
  • Verify your two new functions compile correctly by running:
go build .

You will see a compile error pointing at your main function. This is expected. The old main still calls fetchFromSource with the old signature. The error confirms your new functions are syntactically correct.

Update main to use fetchAll
  • Replace your entire main function with this version that calls fetchAll with a 3-second timeout:
func main() {
	sources := []Source{
		{Name: "Source A", Latency: 500 * time.Millisecond},
		{Name: "Source B", Latency: 2 * time.Second},
		{Name: "Source C", Latency: 10 * time.Second},
		{Name: "Source D", Latency: 800 * time.Millisecond},
	}

	timeout := 3 * time.Second
	fmt.Printf("Per-source timeout: %v\n\n", timeout)

	start := time.Now()
	results := fetchAll(sources, timeout)
	elapsed := time.Since(start)

	fmt.Printf("\n--- Results (took %v) ---\n", elapsed)
	fmt.Printf("Received %d/%d results:\n", len(results), len(sources))
	for _, r := range results {
		fmt.Printf("  - %s\n", r)
	}
}

What changed in main?

  • The goroutine logic has moved into fetchAll, keeping main clean and focused on setup and reporting.
  • The timeout is set to 3 * time.Second, which is long enough for Sources A, B, and D but shorter than Source C's 10-second latency.
  • The output now shows how many sources responded out of the total.
  • Save main.go.

Before you run this, think about what you expect to happen. Source C has a 10-second latency and your timeout is 3 seconds. Will Source C succeed or get cancelled?

  • Run your program by executing:
go run main.go

Source C reports cancelled: context deadline exceeded after about 3 seconds. The total program time drops from ~10 seconds to ~3 seconds. Sources A, B, and D all complete successfully because their latencies are under the 3-second deadline.

Still seeing 10 seconds or a compile error?

  • If you see a compile error about fetchFromSource arguments, make sure you deleted the old main function entirely and replaced it with the new version.
  • If the program still takes 10 seconds, confirm that fetchAll uses context.WithTimeout and passes srcCtx to fetchFromSource.
  • Check that fetchFromSource uses a select statement with <-ctx.Done() instead of the old time.Sleep call.

Why is Source C still blocking my program?

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and your program completes in about 3 seconds.

ⓧ I'd like to double check the full code

Your main.go should contain these sections in order. Scroll up and confirm each is present:

  • Import block with "context", "fmt", "sync", and "time".
  • Source struct (unchanged from the previous step).
  • fetchFromSource accepting ctx context.Context as the first parameter, returning (string, error), using a select on time.After and ctx.Done().
  • fetchAll creating a per-source context.WithTimeout context and deferring cancel().
  • main calling fetchAll(sources, timeout) with a 3-second timeout.

Your program no longer hangs on slow sources. Next up, you will handle what happens when the user presses Ctrl+C while the program is running.

Add Signal Handling for Graceful Shutdown

Your timeout handles slow sources, but what happens if you press Ctrl+C while the program is running? Right now, the OS kills the process immediately. Every goroutine dies mid-flight with no cleanup message and no partial results.

You want Ctrl+C to propagate through the entire context tree so every goroutine exits gracefully. Go's signal.NotifyContext creates a context that cancels when an OS signal arrives. Because your per-source timeout contexts are children of this root context, cancelling the root cascades down to every goroutine automatically.

In this step, get ready to:
  • Wire signal.NotifyContext as the root context in main.
  • Thread the parent context through fetchAll so timeout contexts inherit cancellation.
  • Test graceful shutdown by pressing Ctrl+C mid-run.
Update fetchAll to accept a parent context

Right now, fetchAll creates each timeout context from context.Background(). That means each goroutine's context has no parent that could signal cancellation from outside. You need to pass in a parent context so the timeout contexts become children of the signal-aware root.

  • In main.go, find the fetchAll function signature:
func fetchAll(sources []Source, timeout time.Duration) []string {
  • Add ctx context.Context as the first parameter, so the line now looks like this:
func fetchAll(ctx context.Context, sources []Source, timeout time.Duration) []string {
  • Inside the same function, find this line inside the goroutine:
srcCtx, cancel := context.WithTimeout(context.Background(), timeout)
  • Replace context.Background() with ctx, so it now reads:
srcCtx, cancel := context.WithTimeout(ctx, timeout)

Why does the parent context matter?

Context forms a tree. When you create a timeout context from context.Background(), it is an orphan with no parent that can cancel it externally.

By passing ctx (which will be our signal-aware root), the timeout context becomes a child. If the parent cancels, every child cancels too. This is how Ctrl+C will reach all goroutines.

Add signal.NotifyContext in main

Now you need to create the signal-aware root context in main and pass it to fetchAll.

  • First, update your import block at the top of the file to include the two new packages:
import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"sync"
	"time"
)
  • Now find the first line inside main:
func main() {
	sources := []Source{
  • Replace those two lines with the following, which creates the signal-aware context and adds a user prompt before the sources slice:
func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	fmt.Println("Press Ctrl+C to cancel all operations")
	fmt.Println()

	sources := []Source{

What does this code do?

  • signal.NotifyContext(context.Background(), os.Interrupt) returns a context that cancels when the program receives an interrupt signal (Ctrl+C).
  • defer stop() unregisters the signal handler when main exits, preventing resource leaks.
  • Finally, find the line that calls fetchAll:
results := fetchAll(sources, timeout)
  • Pass ctx as the first argument so the line now reads:
results := fetchAll(ctx, sources, timeout)
  • Save main.go.

Seeing a compile error?

  • If you see imported and not used, double check that your import block includes both "os" and "os/signal" and that your main function uses os.Interrupt.
  • If you see too many arguments in call to fetchAll, make sure you updated the function signature to include ctx context.Context as the first parameter.

Help me fix my compile error in main.go

Test graceful shutdown with Ctrl+C

The context tree is now wired: signal.NotifyContext at the root, with per-source timeout contexts as children. Time to see cancellation cascade in action.

Before you run this, think about what you expect to happen if you press Ctrl+C after about 1 second. Which sources will have completed, and which will report cancellation?

  • Run the program:
go run main.go
  • Press Ctrl+C within the first 2 seconds, before the timeout fires.

You should see Source A complete (it finishes in 500ms), then all remaining sources print their cancellation messages when Ctrl+C hits. The program exits cleanly with a summary showing only the partial results collected so far.

What just happened?

When you pressed Ctrl+C, the OS sent an interrupt signal. signal.NotifyContext detected it and cancelled the root context. That cancellation cascaded to every child timeout context, which caused each goroutine's <-ctx.Done() case to fire.

Every goroutine printed its cancellation message and exited. The WaitGroup unblocked, and main printed whatever results had been collected before the signal arrived. No goroutine was killed abruptly.

Nothing happens when you press Ctrl+C?

  • Make sure you press Ctrl+C quickly (within 2 seconds). If you wait longer than 3 seconds, the per-source timeout fires first and the program exits on its own.
  • Verify that you changed context.Background() to ctx inside the goroutine's context.WithTimeout call. If the timeout contexts still use Background as their parent, they are orphans and won't receive the signal cancellation.

Help me debug why Ctrl+C isn't cancelling my goroutines

✔️ Awesome, I've got everything!

Great. Make sure you have saved main.go.

ⓧ I'd like to double check the full code

Your main.go is now over 30 lines, so scroll through it and confirm these sections are present in order:

  • The import block includes "os" and "os/signal" alongside the existing imports.
  • The Source struct and fetchFromSource function are unchanged from the previous step.
  • fetchAll has ctx context.Context as its first parameter and uses context.WithTimeout(ctx, timeout) internally.
  • main starts with signal.NotifyContext(context.Background(), os.Interrupt) and defer stop().
  • The fetchAll call passes ctx as the first argument: fetchAll(ctx, sources, timeout).

Your program now responds gracefully to Ctrl+C. Every goroutine receives cancellation through the context tree and exits cleanly. Next up, you will add request tracing so that every log line carries a request ID, making concurrent output easy to follow.

Add context.WithValue for Request Tracing

Your program now handles both slow sources and manual cancellation gracefully. Every goroutine responds to its parent context being cancelled, whether by timeout or Ctrl+C.

When multiple goroutines print messages concurrently, their output interleaves. You have no way to tell which log lines belong to the same "request." context.WithValue solves this by attaching a request ID to the context tree that any function in the chain can read, without adding extra parameters to every function signature.

In this step, get ready to:
  • Define a typed context key and helper functions for request ID propagation.
  • Attach a generated request ID to the context and read it in each goroutine.
  • Verify that all log lines share the same request ID prefix.
Define the request ID key and helpers

Go's official context documentation recommends using an unexported struct type as a context value key. A pointer to that struct guarantees package-level uniqueness and prevents collisions with other packages.

  • In main.go, find the line that says type Source struct {. Add the following code above it:
type contextKey struct{ name string }

var requestIDKey = &contextKey{"requestID"}

// WithRequestID attaches a request ID to the context
func WithRequestID(ctx context.Context, id string) context.Context {
	return context.WithValue(ctx, requestIDKey, id)
}

// RequestIDFrom extracts the request ID from a context
func RequestIDFrom(ctx context.Context) string {
	if id, ok := ctx.Value(requestIDKey).(string); ok {
		return id
	}
	return "unknown"
}

What does this code do?

contextKey is an unexported struct type used as a map key inside the context. Because it's unexported and you use a pointer (&contextKey{"requestID"}), no other package can accidentally overwrite your value.

WithRequestID wraps context.WithValue to store a string ID. RequestIDFrom extracts it with a type assertion, falling back to "unknown" if no ID was set.

  • Save the file.
Generate a request ID and attach it to the context

Now you need to create a request ID in main and pass it through the context tree. Every downstream function will be able to read it.

  • In the import block at the top of main.go, find this line:
"fmt"
  • Add "math/rand" on a new line directly below it, so your imports include both:
"fmt"
"math/rand"
  • In the main function, find this line:
defer stop()
  • Add the following lines directly below defer stop(), replacing the existing fmt.Println("Press Ctrl+C to cancel all operations") line:
// Generate a random request ID and attach it to the context
reqID := fmt.Sprintf("req-%d", rand.Intn(10000))
ctx = WithRequestID(ctx, reqID)

fmt.Printf("Starting fetch with request ID: %s\n", reqID)
fmt.Println("Press Ctrl+C to cancel all operations")
fmt.Println()

What does this code do?

rand.Intn(10000) generates a random number to create a unique request ID like req-4281. WithRequestID attaches that ID to the context. Every goroutine that receives this context (or a child of it) can now read the ID.

  • Save main.go and run the program to confirm the request ID prints:
go run main.go

You should see a line like Starting fetch with request ID: req-7392 near the top of the output. The fetch log lines still lack the prefix. You will fix that next.

Seeing an import error?

Make sure "math/rand" is inside the parenthesized import block, on its own line. If you placed it outside the parentheses, Go will reject the syntax.

help me fix an import error in my Go file

Update fetchFromSource to read the request ID

The request ID is attached to the context, but fetchFromSource does not read it yet. You need to extract the ID and prefix every log line with it.

  • In main.go, replace the entire fetchFromSource function with this updated version:
func fetchFromSource(ctx context.Context, src Source) (string, error) {
	reqID := RequestIDFrom(ctx)
	fmt.Printf("[%s] fetching from %s (expected latency: %v)\n", reqID, src.Name, src.Latency)

	select {
	case <-time.After(src.Latency):
		result := fmt.Sprintf("data from %s", src.Name)
		fmt.Printf("[%s] %s completed\n", reqID, src.Name)
		return result, nil
	case <-ctx.Done():
		fmt.Printf("[%s] %s cancelled: %v\n", reqID, src.Name, ctx.Err())
		return "", ctx.Err()
	}
}

What changed?

The first line inside the function calls RequestIDFrom(ctx) to pull the request ID out of the context. Every fmt.Printf now starts with [%s] so the ID appears at the beginning of each log line.

The function signature stays the same. Because the ID travels inside the context, you did not need to add a new parameter.

  • Save main.go.

Before you run this, what do you expect to see in the log output now that every goroutine reads the request ID from the same context tree?

  • Run the program:
go run main.go

Every log line is now prefixed with the same request ID, like [req-4281]. The ID propagated from main through the signal context, into each per-source timeout context, and down into every goroutine. Source C still reports cancellation after 3 seconds, but now you can trace that cancellation back to the same request.

Seeing [unknown] instead of a request ID?

This means the context passed to fetchFromSource does not carry the request ID. Check that your main function has ctx = WithRequestID(ctx, reqID) and that it appears before the call to fetchAll.

help me debug why my context value is not propagating

Why typed keys instead of plain strings?

If you used a plain string like "requestID" as the key, any other package that also uses the string "requestID" would collide with yours. The unexported pointer key makes the value accessible only to code within your package.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your file.

ⓧ I'd like to double check the full code

Your main.go should have these sections in order. Scroll up and confirm each block is present:

  • The import block including "math/rand".
  • The contextKey struct, requestIDKey variable, WithRequestID, and RequestIDFrom functions.
  • The Source struct and updated fetchFromSource that calls RequestIDFrom(ctx) and prefixes all prints with [%s].
  • The fetchAll function (unchanged from the previous step).
  • The main function generating a request ID with rand.Intn(10000) and attaching it with WithRequestID.

Secret mission

First-Result-Wins Race Pattern

Sometimes you only need the fastest response out of many. In this mission, you'll implement a race pattern where the first goroutine to finish automatically cancels all others using context.WithCancel. Only the winner's result survives.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them entirely.

Resources you used:

  • The context-fetcher project directory on your Desktop (contains main.go and go.mod).

Keep everything running

No action needed. Choose this if you want to keep experimenting with context patterns or extend the program further.

  • Your context-fetcher folder stays on your Desktop ready to use whenever you run go run main.go again.

Pause - I'll come back to this later

There are no running processes or services to pause. Your files sit on disk and use negligible storage.

  • No action required. The project is already paused by default since nothing runs in the background.

Delete - I don't want to use this again

Remove the project directory to start fresh.

  • Delete the context-fetcher directory by running this command from your Desktop:
cd ~/Desktop
rm -rf context-fetcher
  • On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q context-fetcher
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see context-fetcher listed.

Nice Work!

Nice Work!

You just built a concurrent Go program that gracefully handles timeouts, manual cancellation, and request tracing through context propagation.

You've learned how to:

  • Use context.WithTimeout to automatically cancel slow operations after a deadline, dropping your program's runtime from 10 seconds to 3.
  • Use signal.NotifyContext to propagate Ctrl+C cancellation through an entire goroutine tree so every in-flight operation exits cleanly.
  • Use context.WithValue with typed keys to pass request-scoped data through the context tree without adding extra function parameters.
  • Secret Mission: Implemented a first-result-wins race pattern using context.WithCancel to cancel all remaining goroutines the instant the fastest source responds.

Ready to quiz yourself?