It was my first time building a server with go and I was figuring out stuff. I've heard about context, but never spent too much time on it. I was too eager to start using...and that's what I did.
I stored all the variables I wanted in context as if it's a HUGE object and kept passing it around the functions. It worked! But it was too late for me to realize that it wasn't how context needs to be used.
It looked something like this.
// ❌ What I tried — treating ctx like a React context store
ctx := context.Background()
ctx = context.WithValue(ctx, "dbConn", db) // storing a DB connection
ctx = context.WithValue(ctx, "config", appConfig) // storing app config
// Then passing ctx everywhere and extracting values like this:
db := ctx.Value("dbConn").(*sql.DB)
We all do have bad code days.
This compiles. It runs. And it's wrong: extracting bunnies from a magic hat. The values are in there, but you have no idea what's inside until you reach in.
And unlike React context, which is typed, Go's context values resolve to any (Go's empty interface). No compiler help. The type assertion happens at runtime:
// ❌ This compiles fine — but panics at runtime if the value is missing,
// the wrong type, or stored under a slightly different key
db := ctx.Value("dbConn").(*sql.DB)
If you're coming from TypeScript, this should set off alarm bells. You've just moved a compile-time guarantee into a runtime panic. Compare that to an explicit parameter. The compiler knows exactly what it is, and can't be passed the wrong thing:
// ✅ Type is in the signature — compiler enforces it, no surprises
func GetUser(ctx context.Context, db *sql.DB, userID string) (*User, error) {...}
So if context isn't for sharing state, what is it actually for?
Two things. That's it.
1. Carrying request-scoped values
requestID, a JWT token, the authenticated userID: values whose lifespan matches the request's lifespan. When the request ends, they're gone.
userID is the one that divides devs. If you're only logging it, "request from user abc123", context is fine. Its lifespan matches the request. But if your handler is calling ctx.Value("userID") to fetch user data or make authorization decisions, that's business logic hiding in context. Pass it explicitly instead: visible in the function signature, enforced by the compiler, testable without a context setup.
The rule: context is for observability. Not for driving logic.
One thing to get right: don't use raw string keys. Using "requestID" as a key means any other package could accidentally collide with it. In Go's context, both the key and the value are typed as any (the empty interface). That's why a raw string like "requestID" as a key is risky. Any other package using the same string collides silently.
Define a private type instead:
// private key type — unexported, so no other package can collide with it
type contextKey string
const requestIDKey contextKey = "requestID"
// middleware sets it
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The private type wraps the string in a new type the compiler treats as distinct, making collisions impossible across package boundaries.
// handler reads it
func handleRequest(w http.ResponseWriter, r *http.Request) {
id, ok := r.Context().Value(requestIDKey).(string)
if !ok || id == "" {
log.Println("warning: requestID missing from context")
id = "unknown"
}
log.Printf("handling request %s", id)
}
The test: does this value die when the request dies? If not, it's a function parameter, not a context value.
2. Cancellation — and why AbortController is the better comparison
Here's a scenario. Your server receives a request and kicks off a fetch to a third-party API. While that fetch is in-flight, waiting on the promise to resolve, your server goes down. The third-party API eventually responds. But there's nobody home. The response arrives, resolves into a void, and the work was wasted.
Or a softer version: the fetch is just taking too long, and you want to bail.
Think of it like this.
You ask your dad to pick up supplies for a party. He grabs his keys, heads out the door. You can picture it. He's already at the store, navigating the aisles, loading the cart.
And then you check the RSVPs. Not enough people are coming. Majority said no.
You need to stop him. But here's the thing. You can only reach him if he has his phone with him. The phone is what makes cancellation even possible. Without it, there's no way to get through. The chore just... continues.
That's ctx. You pass it into the goroutine the same way dad takes his phone with him. It's the thing that keeps the line open between you two.
When you decide to cancel, you call him. That's cancel(). His phone rings. That's ctx.Done(). He checks if he missed a call before heading to the next store. That's select { case <-ctx.Done(): }.
But if he left his phone at home? No ctx. No cancellation. He comes back with a car full of streamers, balloons, and enough snacks for thirty people.
For a party that isn't happening.
You'd get scolded for making him step out for nothing. But nothing compared to explaining why he just spent $300 on a party nobody's coming to.
Note: in Go, "calling dad" isn't literally a phone call. It's
cancel()closing a channel. Every goroutine listening onctx.Done()gets unblocked simultaneously, not one by one. The call analogy is just a way to picture "signalling that work should stop."
In Go, this looks like:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Dad heads out first — he's already in-flight
go dadGetPartySupplies(ctx, []string{"cake", "plates", "balloons"})
// simplified — in real code you'd watch RSVPs concurrently (more on that in post #4)
if !majorityRSVPed(guestList) {
cancel() // dad is already out — this stops him mid-chore
}
defer cancel() is the safety net. Whether cancel() is called explicitly or the function returns normally, it always runs. No goroutine leak. Calling cancel() twice is safe. It's idempotent.
// Dad checks his phone before heading to each store
func dadGetPartySupplies(ctx context.Context, supplies []string) error {
stores := map[string]string{
"cake": "https://api.bakery.com",
"plates": "https://api.costco.com",
"balloons": "https://api.partycity.com",
}
for _, item := range supplies {
// check phone before heading to the next store
select {
case <-ctx.Done():
fmt.Println("party's cancelled, heading home")
return ctx.Err() // context.Canceled
default:
}
if err := fetchFromStore(ctx, stores[item]); err != nil {
return err
}
}
return nil
}
New to Go channels and select? A Tour of Go covers them here.
If you've used AbortController in JS, this is the same idea. You create a controller, pass its signal to fetch, and call abort() when you need to cancel: a timeout, a user navigating away, a component unmounting.
const controller = new AbortController();
fetch('https://api.example.com/data', { signal: controller.signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('fetch was cancelled — no supplies needed');
}
});
controller.abort();
Without AbortController, once fetch starts, you can't stop it. Same problem Go has without ctx. The goroutine just keeps running.
In real code, you often don't cancel manually. You set a deadline instead. context.WithTimeout cancels automatically when time runs out:
// caller sets the deadline — 3 seconds max
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
getUserDetails(ctx, userID)
// fetchFromEndpoint passes ctx to http.NewRequestWithContext,
// so if the deadline fires mid-request, the HTTP call is cut off there too —
// not just between iterations.
func fetchFromEndpoint(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
// ...
}
// function just receives ctx and listens — doesn't need to know about the timeout
func getUserDetails(ctx context.Context, userID string) error {
endpoints := []string{
"https://api.profiles.com/user/" + userID,
"https://api.billing.com/user/" + userID,
"https://api.activity.com/user/" + userID,
}
for _, url := range endpoints {
select {
case <-ctx.Done():
return ctx.Err() // context.DeadlineExceeded if timeout, context.Canceled if manual
default:
}
if err := fetchFromEndpoint(ctx, url); err != nil {
return err
}
}
return nil
}
The select catches cancellation between API calls. fetchFromEndpoint passes ctx to the underlying HTTP client. So if the deadline fires mid-request, the HTTP call gets cut off there too. You don't need to check a timer yourself. The context does it.
The default case runs immediately if the channel isn't closed yet, so the goroutine keeps going without blocking. Only when cancel() is called does the case <-ctx.Done() branch runs.
It's ctx that maps to AbortSignal, not AbortController. You pass signal into fetch the same way you pass ctx into a goroutine. Neither initiates the cancellation. They just listen for it. cancel() is the AbortController equivalent. The thing you call to trigger the stop.
| JS | Go |
|---|---|
controller.signal |
ctx |
signal passed to fetch
|
ctx passed to goroutine |
controller.abort() |
cancel() |
addEventListener('abort', ...) |
<-ctx.Done() |
ctx.Done() is a function that returns a channel which closes when the context is cancelled. The goroutine's equivalent of an abort event listener.
AbortController says: here's a signal you can pass to fetch, call abort() when you're done with it.
context.WithCancel says: here's a context you can pass to goroutines, call cancel() when you're done with it.
Same word. Completely different problem.
Same word. Completely different job.
React's context shares state down a component tree. Go's context does two things: carries request-scoped values that need to travel with a request, and propagates cancellation signals across goroutines.
The confusion is understandable. The word is the same. The mental model is completely different.
Next time you reach for context.WithValue in Go, ask yourself: does this value die when the request dies? If not, it's a function parameter, not a context value.
Next time you spawn a goroutine, ask yourself: does it have a ctx? If not, you have no way to stop it.
I learnt this the hard way. A friend who'd been writing Go for years looked at my code and said: 'Did you even read about context?'
Curious what happens when multiple goroutines share the same context — and one of them gets to decide when everyone stops? I'm publishing a follow-up post next week. We'll build on the party supplies story to get there.
What Go misconceptions have cost you the most time? Drop them in the comments — I'd genuinely love to hear them.
Keep Up With My Go Journey
I'm documenting my transition from frontend to backend in public. If you're on a similar path, or just enjoy watching someone learn and fail publicly, follow along:
Top comments (0)