How to stop runaway goroutines and prevent memory leaks.
Chapter 16: Knowing When to Quit
The archive was silent, save for the hum of the server rack in the corner. It was 8:00 PM, and Ethan was the only one left—except for Eleanor, who was inspecting a stack of punch cards near the window.
"I have a memory leak," Ethan said, breaking the silence. He sounded exhausted.
"Do you?" Eleanor asked, not looking up.
"Well, not a leak, exactly. But look at my dashboard." He pointed to a monitor displaying a jagged, climbing line. "This is my API. Every time a user searches for a product, I spawn a goroutine to query the database and the recommendation engine. If the user gets bored and closes their browser, my server keeps working."
"Because you didn't tell it to stop," Eleanor said, walking over.
"I thought the HTTP handler handled that?"
"It handles the connection," Eleanor corrected. "But your goroutines are independent. You fired them and walked away. They are still running, burning CPU cycles to calculate results for a user who has already left the building. You are being rude to your server."
"So how do I tell them the user left?"
"You pass the Context."
The First Argument
Eleanor pulled up a chair. "In Go, context.Context is the standard way to carry deadlines, cancellation signals, and request-scoped values across API boundaries. It is almost always the first argument of a function."
She opened Ethan’s code.
// Bad: No way to stop this function once it starts
func SlowDatabaseQuery(id string) string {
time.Sleep(5 * time.Second) // Simulate work
return "Product Details for " + id
}
func HandleSearch(w http.ResponseWriter, r *http.Request) {
// We ignore the request context!
result := SlowDatabaseQuery("12345")
fmt.Fprintln(w, result)
}
"If I hit this endpoint and cancel the request after one second," Eleanor said, "your SlowDatabaseQuery still sleeps for the full five seconds. Multiply that by a thousand users, and your server crashes."
She refactored the code.
// Good: We accept a Context
func SlowDatabaseQuery(ctx context.Context, id string) (string, error) {
// We use a select statement to listen for cancellation
select {
case <-time.After(5 * time.Second): // Simulate work finishing
return "Product Details for " + id, nil
case <-ctx.Done(): // The signal that we should stop
return "", ctx.Err() // Usually "context canceled"
}
}
func HandleSearch(w http.ResponseWriter, r *http.Request) {
// The http.Request already carries a Context tied to the client connection
ctx := r.Context()
result, err := SlowDatabaseQuery(ctx, "12345")
if err != nil {
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
fmt.Fprintln(w, result)
}
"Look at the select block," Eleanor pointed out. "We are racing two things: the work finishing, or the context finishing. ctx.Done() is a channel that closes when the context is canceled. If the user disconnects, ctx.Done() fires immediately, and your function returns. You save four seconds of work."
The Timeout (Setting Boundaries)
"That handles cancellation," Ethan said. "But what if the database is just broken and hangs forever? The user waits, the connection stays open..."
"Then you set a deadline," Eleanor said. "Never let a process run forever. We use context.WithTimeout."
She created a new example.
func CallExternalAPI() error {
// 1. Create a derived context that dies after 100 milliseconds
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
// 2. ALWAYS defer the cancel function to release resources
defer cancel()
// 3. Pass this strict context to the worker
req, _ := http.NewRequestWithContext(ctx, "GET", "http://slow-api.com", nil)
client := &http.Client{}
_, err := client.Do(req)
return err
}
"This is aggressive," Ethan noted. "100 milliseconds?"
"If the external API is slower than that, we don't want the answer," Eleanor said firmly. "This protects your system. If their server hangs, yours doesn't pile up thousands of waiting connections. You fail fast and move on."
"And defer cancel()?"
"Crucial. If the work finishes in 10 milliseconds, the timer is still ticking in the background. Calling cancel() stops the timer and frees up the memory immediately. Always defer cancel."
The Value (Use with Caution)
"I saw context.WithValue in the documentation," Ethan said. "Can I use that to pass user objects and config settings down to my functions?"
Eleanor frowned. "You can, but you usually shouldn't. WithValue is for request-scoped data—like a Request ID or an authentication token. It is untyped and invisible. If you use it to pass required function arguments, you are making your code opaque."
"Opaque?"
"If a function needs a database connection, pass it as an argument: func(db *DB). Do not hide it inside ctx where no one can see it. Explicit is better than implicit."
The Chain of Command
Ethan looked at his dashboard. The memory usage was flattening out.
"It propagates," he realized. "If I cancel the parent context in HandleSearch, it cancels the child context in SlowDatabaseQuery, which cancels the HTTP request to the database..."
"Exactly," Eleanor said. "It is a chain of command. When the top level says 'stop,' the order goes all the way down the tree. Every function cleans up its own mess and returns."
She stood up and picked up her stack of punch cards.
"A server is not a trash can, Ethan. Do not fill it with abandoned processes. Be polite. When the user leaves, stop working."
Key Concepts from Chapter 16
context.Context:
The standard interface for managing request lifetime. It carries deadlines, cancellation signals, and request-scoped values. It is immutable and thread-safe.
ctx.Done():
A channel that closes when the context is canceled or times out. Use it in a select statement to return early if work is no longer needed.
context.Background():
The root context. Use this when you are starting a main function or a top-level process and have no existing context to inherit from.
context.WithCancel(parent):
Returns a copy of the parent context with a new Done channel. Calling the returned cancel() function closes that channel.
context.WithTimeout(parent, duration):
Returns a copy of the parent that automatically cancels after a set time.
-
Best Practice: Always
defer cancel()to release resources as soon as the function returns, even if the timeout hasn't fired.
context.TODO():
A placeholder context. Use it when you are refactoring and haven't figured out where the context should come from yet.
context.WithValue:
Used for passing request-scoped data (like Trace IDs). Do not use it for optional parameters or core dependencies (like loggers or database handles). Explicit arguments are clearer.
Next chapter: JSON and Tags. Ethan tries to parse a configuration file and learns that Go’s struct tags are the closest thing the language has to magic.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (1)
This was a great way to explain context — the story format makes a tricky concept feel very natural.
The idea of being polite to your server really clicked for me. It’s easy to spawn goroutines and forget they keep working even after the user is gone. Seeing cancellation, timeouts, and ctx.Done() framed as responsibility (not just syntax) is a strong takeaway.
I also appreciated the clear warning around context.WithValue. That’s a mistake I’ve seen (and made) early on.
Looking forward to the next chapter — these feel like lessons you remember, not just read.