I went to a restaurant to grab a meal. Well... the waiting time was crazy.
As I sat there waiting, I noticed a guy who had clearly reached his limit. He signalled the waiter and said he couldn't wait any longer.
The waiter immediately signalled the kitchen to cancel the order.
That moment got me thinking.
What happens when a customer cancels an order while the kitchen is preparing it?
If the kitchen has already started preparing the meal, continuing to cook it means the restaurant may end up with food that nobody wants.
The waiter’s signal to the kitchen helps prevent that waste.
And then my backend engineering brain kicked in:
This is exactly the kind of problem Go’s
context.Contexthelps us solve.
Let me explain.
The Restaurant Analogy
Imagine a restaurant with three main actors:
- The customer.
- The waiter.
- The kitchen.
Now map that to a typical Go backend application:
| Restaurant | Go Backend |
|---|---|
| Customer | HTTP client |
| Waiter | HTTP request handler |
| Kitchen | Database / downstream service |
| Meal preparation | Database query / business operation |
| Cancelled order | Cancelled request context |
| Signal to kitchen | Context cancellation |
| Stop preparing the meal | Stop ongoing work and release resources |
The waiter receives the customer's order and passes it to the kitchen.
But the waiter also has information about the order's lifecycle.
If the customer says:
"I can't wait anymore. Cancel my order."
The waiter communicates that cancellation to the kitchen.
The kitchen can then stop preparing the meal, provided it hasn't reached a point where stopping is no longer possible.
This is similar to how cancellation propagates through Go's context system.
What Is Go's Context?
In Go, context.Context carries deadlines, cancellation signals, and request-scoped values across API boundaries and between goroutines.
It is commonly used when handling:
- HTTP requests.
- Database queries.
- External API calls.
- Background operations tied to a request.
- Concurrent goroutines.
The important part for this article is cancellation.
A context allows one part of your application to communicate:
"This work is no longer needed. Stop as soon as you can."
That is useful because backend systems often perform work that may become unnecessary before it finishes.
For example:
- A user closes their browser.
- A mobile app cancels a request.
- A request deadline expires.
- A service calling your API times out.
- A user navigates away from a page.
- A downstream operation is no longer needed.
Without cancellation, your application may continue doing unnecessary work.
The Restaurant Flow in Go
Let's visualize the analogy.
sequenceDiagram
actor Customer
participant Waiter as HTTP Handler
participant Kitchen as Database
participant CustomerDB as Database
Customer->>Waiter: Place order / HTTP request
Waiter->>Kitchen: Pass order with context
Kitchen->>CustomerDB: Execute query
Customer-->>Waiter: Cancel request
Waiter-->>Kitchen: Context cancellation
Kitchen-->>CustomerDB: Stop query if supported
CustomerDB-->>Kitchen: Release resources
Kitchen-->>Waiter: Cancellation error
Waiter-->>Customer: Request cancelled
The analogy is not a perfect one-to-one mapping.
In particular, a database is not literally a kitchen, and context cancellation does not guarantee that every database query stops instantly.
But the core idea is the same:
Propagate cancellation to work that is no longer needed.
Let's See It in Go
Imagine we have an API endpoint that fetches a customer's orders.
A client sends:
GET /customers/123/orders
The handler needs to query the database.
A common mistake is to execute the query without using the request's context.
Without Context Cancellation
package main
import (
"database/sql"
"net/http"
)
type OrderHandler struct {
DB *sql.DB
}
func (h *OrderHandler) GetOrders(
w http.ResponseWriter,
r *http.Request,
) {
customerID := 123
rows, err := h.DB.Query(
"SELECT id, amount FROM orders WHERE customer_id = ?",
customerID,
)
if err != nil {
http.Error(
w,
"Failed to fetch orders",
http.StatusInternalServerError,
)
return
}
defer rows.Close()
// Process rows...
}
Here, we execute a database query.
But what happens if the client disconnects while the database is still working?
Depending on the database driver and how the query is executed, the database operation may continue even though the HTTP request is no longer useful.
The waiter has effectively lost the customer, but the kitchen may still be preparing the meal.
That is wasted work.
With Context Cancellation
Go's database/sql package provides context-aware methods such as:
QueryContext()
ExecContext()
PrepareContext()
BeginTx()
Let's use QueryContext().
package main
import (
"context"
"database/sql"
"net/http"
)
type OrderHandler struct {
DB *sql.DB
}
func (h *OrderHandler) GetOrders(
w http.ResponseWriter,
r *http.Request,
) {
ctx := r.Context()
customerID := 123
rows, err := h.DB.QueryContext(
ctx,
"SELECT id, amount FROM orders WHERE customer_id = ?",
customerID,
)
if err != nil {
if ctx.Err() != nil {
http.Error(
w,
"Request cancelled",
http.StatusRequestTimeout,
)
return
}
http.Error(
w,
"Failed to fetch orders",
http.StatusInternalServerError,
)
return
}
defer rows.Close()
// Process rows...
}
The important line is:
ctx := r.Context()
And then:
h.DB.QueryContext(ctx, query, args...)
We are passing the request's context to the database operation.
Now, if the request context is cancelled, the database driver can be notified and may cancel the query.
The kitchen receives the cancellation signal.
How Cancellation Actually Propagates
Let's break it down.
Step 1: The customer places an order
The HTTP client sends a request.
GET /customers/123/orders
The Go server creates a request context.
ctx := r.Context()
The waiter now has the customer's order and its context.
Step 2: The waiter sends the order to the kitchen
The handler passes the context to the database.
rows, err := db.QueryContext(
ctx,
"SELECT id, amount FROM orders WHERE customer_id = ?",
customerID,
)
The kitchen begins preparing the meal.
Step 3: The customer cancels
The client disconnects, or the request deadline expires.
The request context becomes cancelled.
Conceptually:
Customer cancels
↓
HTTP request context cancelled
↓
Database query receives cancellation
↓
Driver attempts to stop the query
↓
Resources can be released
This is the signal from the waiter to the kitchen.
Step 4: The database operation stops if cancellation is supported
The database driver receives the cancellation signal and attempts to interrupt the query.
The query may return an error such as:
context.Canceled
or:
context.DeadlineExceeded
The handler can then stop processing the result.
Important: Context Cancellation Is Cooperative
This is where the analogy needs a little technical precision.
In Go, context cancellation is not a forceful kill switch.
Calling:
cancel()
does not magically terminate every goroutine or operation using that context.
Instead, cancellation is a signal.
Code must observe that signal or use APIs that support it.
For example:
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
return result
}
The goroutine checks whether the context has been cancelled.
If it has, it stops its work.
The same principle applies to database operations.
A context-aware database method can pass cancellation to the driver, but the actual ability to interrupt the database operation depends on the driver and database implementation.
The waiter can send the cancellation signal. The kitchen must be capable of responding to it.
A Practical Go Example: Context-Aware Work
Let's create a small example that simulates a kitchen preparing a meal.
package main
import (
"context"
"fmt"
"time"
)
func prepareMeal(ctx context.Context) error {
fmt.Println("Kitchen: Started preparing the meal")
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
fmt.Println(
"Kitchen: Order cancelled. Stopping preparation.",
)
return ctx.Err()
case <-time.After(1 * time.Second):
fmt.Printf(
"Kitchen: Preparing step %d/5\n",
i,
)
}
}
fmt.Println("Kitchen: Meal ready!")
return nil
}
func main() {
ctx, cancel := context.WithCancel(
context.Background(),
)
defer cancel()
go func() {
time.Sleep(2 * time.Second)
fmt.Println(
"Customer: I can't wait anymore. Cancel my order.",
)
cancel()
}()
err := prepareMeal(ctx)
if err != nil {
fmt.Println("Result:", err)
}
}
Possible output
Kitchen: Started preparing the meal
Kitchen: Preparing step 1/5
Kitchen: Preparing step 2/5
Customer: I can't wait anymore. Cancel my order.
Kitchen: Order cancelled. Stopping preparation.
Result: context canceled
Instead of continuing until the meal is ready, the kitchen stops when it receives the cancellation signal.
This is the behavior we want when work is no longer useful.
Why This Matters in Backend Engineering
Now let's move beyond restaurants.
Imagine a fintech API handling a payment request.
The request might involve:
- Validating the customer.
- Checking account details.
- Fetching transaction history.
- Calling a payment provider.
- Writing transaction records.
- Returning a response.
What happens if the client disconnects after step 3?
Should every operation continue blindly?
Not necessarily.
Some operations may no longer be needed.
For example, if you are fetching transaction history solely to build a response that the client will never receive, cancellation can prevent unnecessary work.
A context-aware query can help stop that operation.
func (s *PaymentService) GetTransactionHistory(
ctx context.Context,
customerID string,
) ([]Transaction, error) {
rows, err := s.db.QueryContext(
ctx,
`
SELECT id, amount, status
FROM transactions
WHERE customer_id = ?
`,
customerID,
)
if err != nil {
return nil, err
}
defer rows.Close()
var transactions []Transaction
for rows.Next() {
var tx Transaction
if err := rows.Scan(
&tx.ID,
&tx.Amount,
&tx.Status,
); err != nil {
return nil, err
}
transactions = append(
transactions,
tx,
)
}
if err := rows.Err(); err != nil {
return nil, err
}
return transactions, nil
}
The handler calls the service with the request context:
func (h *PaymentHandler) GetHistory(
w http.ResponseWriter,
r *http.Request,
) {
ctx := r.Context()
transactions, err :=
h.paymentService.GetTransactionHistory(
ctx,
"customer-123",
)
if err != nil {
if ctx.Err() != nil {
return
}
http.Error(
w,
"Failed to fetch transaction history",
http.StatusInternalServerError,
)
return
}
// Write response...
_ = transactions
}
The cancellation signal travels through the application layers.
HTTP Handler
│
│ context.Context
▼
Payment Service
│
│ context.Context
▼
Repository
│
│ QueryContext
▼
Database Driver
│
▼
Database
This is one of the reasons context is so important in Go architecture.
Context Is Not Just for Databases
The restaurant analogy can be extended to other operations.
1. HTTP Calls to External Services
Imagine your Go service calls another API to retrieve customer information.
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://example.com/customers/123",
nil,
)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
If the context is cancelled, the HTTP request can be cancelled as well.
The waiter has told another kitchen:
"The customer no longer needs this order."
2. Goroutines
Suppose you launch a goroutine to perform background work.
func processOrder(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// Perform a unit of work.
}
}
}
The goroutine listens for cancellation.
When the context is cancelled, it exits.
This prevents goroutines from running indefinitely after the request that created them has ended.
3. Deadlines
Sometimes the customer does not explicitly cancel.
They simply say:
"If my meal isn't ready in 10 minutes, I'm leaving."
That is a deadline.
In Go:
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
Now the operation has a maximum duration of 5 seconds.
If it takes longer, the context is cancelled.
err := prepareMeal(ctx)
if err != nil {
if errors.Is(
err,
context.DeadlineExceeded,
) {
fmt.Println("The order took too long.")
}
}
The restaurant analogy is straightforward:
A deadline is the customer's maximum acceptable waiting time.
Cancellation vs. Business Transactions
There is another important distinction, especially in fintech.
Not every operation should be cancelled simply because the client disconnected.
For example:
Customer submits payment
↓
Validate payment
↓
Debit account
↓
Record transaction
↓
Send response
If the client disconnects after the debit has succeeded, you must not assume the payment was cancelled.
A payment may already have been committed.
The restaurant analogy has limits here.
A customer cancelling before the meal is prepared is different from cancelling after the meal has already been served.
In backend systems:
- Read-only queries are often good candidates for request cancellation.
- Long-running reports may be cancellable.
- External API calls may support cancellation.
- Database writes may need to complete once a transaction has reached a critical point.
- Financial operations require idempotency, consistency, and clear transaction semantics.
Context cancellation controls the lifetime of work. It does not automatically roll back business actions that have already been committed.
This distinction is critical when designing reliable payment systems.
The Resource Management Benefit
Why should backend engineers care about stopping unnecessary work?
Because every ongoing operation consumes resources.
For example:
Database resources
- Database connections.
- Query execution capacity.
- Memory.
- CPU.
- Locks, depending on the operation.
Application resources
- Goroutines.
- Memory allocations.
- Network connections.
- Worker capacity.
- CPU cycles.
External service resources
- API requests.
- Rate limits.
- Connection pools.
- Provider capacity.
If a client has already gone away, continuing unnecessary work can increase system load.
In a high-traffic backend, this can become expensive.
One cancelled request is not a big deal.
But thousands of cancelled requests that continue processing can contribute to resource pressure.
This is why cancellation is not merely a convenience.
It is part of building efficient and resilient services.
Best Practices for Using Context in Go
1. Accept context as the first parameter
func GetOrders(
ctx context.Context,
customerID string,
) ([]Order, error)
This makes cancellation and deadlines explicit.
2. Pass context through your application layers
Handler
↓
Application
↓
Repository
↓
Database
Avoid dropping the context somewhere in the middle.
3. Use context-aware APIs
For databases:
db.QueryContext(ctx, query)
db.ExecContext(ctx, query)
For HTTP:
http.NewRequestWithContext(
ctx,
method,
url,
body,
)
For goroutines:
select {
case <-ctx.Done():
return ctx.Err()
}
4. Do not create a new background context unnecessarily
Avoid:
func GetOrders(
ctx context.Context,
) error {
ctx = context.Background()
// Original cancellation signal is lost.
return nil
}
This breaks the cancellation chain.
Prefer:
func GetOrders(
ctx context.Context,
) error {
// Continue using the caller's context.
return nil
}
5. Always release resources
For example:
rows, err := db.QueryContext(
ctx,
query,
)
if err != nil {
return err
}
defer rows.Close()
Cancellation helps stop work, but proper cleanup is still your responsibility.
6. Do not use context values as a general-purpose parameter bag
Context values are intended for request-scoped data that crosses API boundaries, such as trace IDs.
Avoid stuffing business parameters into context:
ctx = context.WithValue(
ctx,
"customerID",
customerID,
)
Prefer explicit function parameters:
func GetOrders(
ctx context.Context,
customerID string,
) error
Your code becomes clearer and easier to test.
The Bigger Lesson
That restaurant experience reminded me of a fundamental principle in software engineering:
When work is no longer needed, stop it as early as possible.
Good backend systems are not only about making operations successful.
They are also about knowing when to stop.
A request that has been cancelled should not blindly continue consuming resources if the work can be safely abandoned.
Go's context provides a clean mechanism for communicating cancellation across layers of an application.
From the HTTP handler to the application layer, from the application layer to the repository, and from the repository to the database driver.
Just like the waiter who tells the kitchen:
"The customer cancelled the order. Don't prepare something that will go to waste."
Final Takeaway
The next time you write:
db.QueryContext(ctx, query)
Remember the waiter.
The waiter knows the customer may no longer be waiting.
The kitchen needs to know that too.
And in backend engineering, context is the signal that helps your system know when work should stop.
Top comments (0)