DEV Community

Cover image for Go Internals for Interviews: Context
David Horvat
David Horvat

Posted on

Go Internals for Interviews: Context

When I first started working as a junior software developer, I remember seeing context used throughout our codebase. At the time, I never questioned it. I didn’t really understand what problem it solved, but I assumed it was there for a good reason. Later, when I began building my own side projects, I simply stopped using it. I didn’t see a clear benefit and treated it as unnecessary boilerplate that only added extra code. That ignorance eventually caught up with me. Fortunately, the projects I was working on were small experiments, so the absence of context (along with many other useful concepts I hadn’t learned yet) didn’t cause any serious damage. In fact, those experiences ended up teaching me why context exists and why it matters. Looking back, I realized that many developers know that context is part of Go, but they either don’t fully understand its purpose or only know it at a surface level. This article aims to change that. My goal is to remove the mystery around context, explain why it exists, and help you build a solid knowledge for using it properly in real-world Go applications while also focusing on possible interview questions you may encounter regarding context in Go.

1) What Is context.Context ?

Maybe the best way to understand context.Context is to start with the problem it solves.

Imagine you have an API endpoint that, when triggered, performs some long-running operations. This could be a complex database query, a call to a third-party API, or perhaps it spins up a background goroutine to perform additional work. From the client’s perspective, all they see is that spinning circle that tells them the request is still being processed.

But users are impatient. If the request takes too long, the client might simply close the tab or navigate away. Now consider what happens on the server.

If your code is not using context, the long-running operations you started will continue running until they finish. The database query will keep executing, the third-party API call will continue waiting for a response, and any goroutines you started will keep doing their work even though the client that initiated the request is no longer there and no longer cares about the result.

In other words, the server continues to spend CPU time, memory, and I/O resources on work that has become completely pointless and this is exactly the problem context was designed to solve.

context.Context is as a request-scoped object that carries cancellation signals, deadlines, and request-scoped values across API boundaries and between goroutines. It provides a structured way to coordinate the lifetime of work that spans multiple function calls and concurrent operations.

At a deeper level, context answers a fundamental question that every concurrent system eventually faces: “How do we coordinate the lifetime of work that spans multiple function calls and multiple goroutines?”

2) context.Context In Practice

In practice, context.Context serves several closely related purposes, all centered around managing the lifecycle of work in concurrent systems.

The first and most common use of a context is propagating cancellation. A context can signal that ongoing work should stop because the original operation that initiated it is no longer relevant. This situation commonly occurs when a client disconnects from a request, when a request is explicitly canceled, or when the server begins shutting down.

When a context is canceled, every operation that depends on that context can detect the signal and terminate early instead of continuing to run unnecessarily. This mechanism allows different parts of an application to react to the same cancellation event in a coordinated way.

In Go, cancellation is usually handled by listening to the Done() channel exposed by the context.

func processData(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second):
        fmt.Println("work finished")
        return nil

    case <-ctx.Done():
        fmt.Println("work canceled:", ctx.Err())
        return ctx.Err()
    }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the function performs some simulated work that takes five seconds. However, if the context is canceled before the work finishes, the function immediately exits instead of continuing to run.

Another important responsibility of a context is propagating deadlines and timeouts. In many systems, operations should not run indefinitely. A request may only be allowed to run for a limited amount of time before it must be terminated. Contexts allow developers to attach a deadline or timeout to an operation so that once the specified time is reached, the context is automatically canceled. This can be done using context.WithTimeout or context.WithDeadline.

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

err := processData(ctx)
if err != nil {
    fmt.Println("operation stopped:", err)
}
Enter fullscreen mode Exit fullscreen mode

In this case, the context automatically cancels itself after two seconds. Any function receiving this context can observe the cancellation and stop its work accordingly. It is important that function logic listens for ctx.Done() channel.

This mechanism is widely used in network operations such as HTTP requests and database queries. For example, the Go HTTP client supports contexts directly:

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
if err != nil {
    return err
}

resp, err := http.DefaultClient.Do(req)
Enter fullscreen mode Exit fullscreen mode

If the context times out or is canceled, the HTTP request will be aborted automatically.

Contexts can also carry request-scoped metadata. Sometimes information associated with a request needs to travel across multiple layers of an application. Examples of this include request identifiers used for logging, authentication data, or tracing information used by observability systems. A value can be attached to a context using context.WithValue.

type contextKey string

const requestIDKey contextKey = "request_id"

func main() {
    ctx := context.WithValue(context.Background(), requestIDKey, "req-123")

    handleRequest(ctx)
}

func handleRequest(ctx context.Context) {
    requestID := ctx.Value(requestIDKey)
    fmt.Println("handling request:", requestID)
}
Enter fullscreen mode Exit fullscreen mode

Although this mechanism is useful, it should be used sparingly and primarily for request-scoped data that needs to cross API boundaries.

It is important to not to use context.Context as a standalone object.

Nothing stops you from initializing countless root context.Context object, but that defeats its purpose and this is not how context should be used. Context objects form a hierarchical structure that resembles a tree.

A parent context can create child contexts, and those children may create their own descendants. One of the key properties of this structure is that cancellation propagates downward through the tree. If a parent context is canceled, all of its child contexts are canceled as well.

parentCtx, cancel := context.WithCancel(context.Background())

childCtx, _ := context.WithCancel(parentCtx)

go func() {
    <-childCtx.Done()
    fmt.Println("child context canceled")
}()

time.Sleep(time.Second)
cancel()
Enter fullscreen mode Exit fullscreen mode

In this example, canceling the parent context automatically cancels the child context as well.

This model makes it easier to manage complex workflows that consist of multiple smaller operations, because canceling the root of the operation automatically stops all related work.

This behavior becomes particularly useful in web applications. In most Go web frameworks, each incoming request is associated with its own context.

For example, in the standard net/http package, the request context can be accessed directly from the request object:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    result, err := slowDatabaseQuery(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprintln(w, result)
}
Enter fullscreen mode Exit fullscreen mode

If the client disconnects, if the request times out, or if the server begins shutting down, the request's context will automatically be canceled.

Because this context is passed through the entire request processing chain, downstream operations such as database queries, RPC calls, or background goroutines can observe the cancellation signal and terminate early.

For example, many database drivers in Go accept a context directly:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
Enter fullscreen mode Exit fullscreen mode

If the request context is canceled, the database query will also be canceled.

This behavior prevents a range of common issues in concurrent systems, including goroutine leaks, wasted CPU cycles, stuck I/O operations, and unnecessary memory usage.

3) Why Is context.Context Passed Explicitly ? (for the nerds)

One of the most important design decisions in Go is that context.Context is passed explicitly through function parameters, usually as the first argument.

func DoSomething(ctx context.Context, ...)
Enter fullscreen mode Exit fullscreen mode

This pattern is not accidental. It reflects a deeper design philosophy about how work should be managed in concurrent systems.

A context represents the ownership and lifetime of work. If a function performs an operation that may take time, block on I/O, or spawn additional goroutines, the lifetime of that work must be clearly defined. By requiring the context to be passed explicitly, Go forces developers to make that lifecycle visible and intentional.

When a context is passed through function parameters, it becomes immediately clear which operations are tied to the lifecycle of a particular request or task. Each function in the call chain receives the same context and therefore participates in the same cancellation and timeout policies.

For example, consider a typical request flow in a service:

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    user, err := getUser(ctx, 42)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprintln(w, user)
}

func getUser(ctx context.Context, id int) (*User, error) {
    return userRepositoryFindByID(ctx, id)
}

func userRepositoryFindByID(ctx context.Context, id int) (*User, error) {
    row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)

    var user User
    err := row.Scan(&user.ID, &user.Name)
    return &user, err
}
Enter fullscreen mode Exit fullscreen mode

In this example the context flows through multiple layers of the application — from the HTTP handler, to the service layer, and finally to the database query. If the client disconnects or the request times out, the cancellation signal propagates through the entire call chain. The database driver receives the cancellation signal and can stop the query early.

Passing the context explicitly also gives developers precise control over how different parts of a system behave. Different branches of work can create child contexts with their own deadlines or cancellation rules while still remaining connected to the original request.

func processRequest(ctx context.Context) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    callExternalAPI(ctx)
}
Enter fullscreen mode Exit fullscreen mode

Here, a new child context is created with a shorter timeout. The operation is still tied to the parent request, but it now has stricter limits for how long it is allowed to run.

This explicit design prevents a number of subtle but serious problems. Because the context must be passed intentionally, there is no hidden coupling between unrelated parts of the system. One piece of code cannot accidentally cancel work it does not own, and developers do not need to rely on implicit behavior that is difficult to reason about.

3.1. The deeper reason behind context

The explicit nature of context.Context is also rooted in a broader philosophy behind Go’s design.

The language intentionally avoids patterns such as global variables, thread-local storage, or implicit request state. While these approaches might seem convenient at first, they introduce hidden dependencies between parts of a program. They make code harder to test and significantly complicate reasoning about concurrent execution.

Instead, Go encourages developers to make important dependencies visible.

In practical terms, this leads to a simple rule of thumb:

If a function can block, perform I/O, or start goroutines, the lifetime of that work should be visible in its signature.

This is why idiomatic Go functions that perform meaningful work almost always accept a context parameter.

func DoSomething(ctx context.Context, ...)
Enter fullscreen mode Exit fullscreen mode

4) context.Background()

context.Background() represents root context.

Root context is simply context that all other context objects originate from, but as purpose of this article is interview preparation, here is the interview ready definition:

A root context is a context that has no parent and is used as the starting point of a context tree. All other contexts are derived from a root using functions like context.WithCancel, context.WithTimeout, or context.WithValue

4.1. context.Background() vs context.TODO()

Go provides two root contexts: context.Background() and context.TODO() and one of the questions you might encounter is what is the difference between them. You might be caught off guard right there, but answer to this can not be simpler than it is.

context.Background() is root context that is used in main functions, initialization code, and other top-level entry points where there is no existing request or cancellation signal to inherit from.

It represents the context that is never canceled, has no deadline, and carries no values. In practice, it serves as the starting point of a context tree from which other contexts can be derived.

context.TODO()is root context that is used as a temporary placeholder when a context is required by an API but it is not yet clear which specific context should be passed.

Although it behaves the same as context.Background() at runtime, its purpose is different: it signals that context handling in that part of the code is incomplete and should be revisited and properly wired in later.

4.2. Why cancellation requires ownership

By now, you’ve probably understood that a root context cannot be canceled. But what would you say if someone asks you why?

Answers like “because it’s implemented that way” or “because there is no cancel() function” sound very junior, maybe even worse.

So let’s give a proper explanation to that question.

Cancellation in Go is fundamentally ownership-based. Canceling work only makes sense when someone clearly owns the lifetime of that work. An HTTP request owns the operations it starts. A parent goroutine owns the goroutines it spawns. A timeout owns the moment at which an operation should stop. In all of these cases, there is a clear creator and a clear point in time when cancellation should happen.

A cancelable context therefore must have two things: a creator and an explicit moment when cancellation is appropriate.

context.Background() has neither. It does not represent a request, a task, or a bounded operation. It is not tied to any lifecycle. Because there is no owner, there is no one who has the authority to cancel it.

This is why root contexts in Go are deliberately immutable. context.Background() is never canceled, has no deadline, and cannot be canceled by design. If it were cancelable, canceling it would effectively cancel everything in the program that derived from it. Cancellation would turn into global state, and reasoning about lifetimes would quickly fall apart. Go avoids this entirely by enforcing a strict rule: only derived contexts can be canceled, never root contexts.

This rule becomes clearer when you look at how cancelable contexts are created. Cancelation always comes with an explicit cancel function:

ctx, cancel := context.WithCancel(parent)
defer cancel()
Enter fullscreen mode Exit fullscreen mode

Here, ownership is obvious. The code that calls WithCancel owns the context and is responsible for deciding when cancellation should occur. Calling cancel() affects only that context and its descendants — nothing else. Cancellation is explicit, local, and predictable.

context.Background() has no associated cancel() function precisely because no one owns it. Without ownership, cancellation would be arbitrary, and arbitrary cancellation is dangerous in concurrent systems.

5) ctx.Done() vs ctx.Err()

A common interview question about context is what the difference is between ctx.Done() and ctx.Err(), and when you would use each.

At a high level, ctx.Done() is used for coordination, while ctx.Err() is used to understand the reason for cancellation.

The Done method returns a receive-only channel. That channel is closed when the context is canceled or when its deadline expires. You do not receive a value from it, but simply wait for it to close. Because of that, Done is typically used inside a select statement, especially in goroutines or long-running operations that need to react to cancellation.

For example:

select {
case <-ctx.Done():
    return ctx.Err()
case msg := <-ch:
    // process message
}
Enter fullscreen mode Exit fullscreen mode

In this pattern, the goroutine blocks until either some work arrives on channel or the context is canceled. The role of Done here is notification. It tells you that the context is no longer valid and that you should stop what you are doing.

The Err method, on the other hand, returns an error. It returns nil as long as the context is still active. After the context has been canceled or its deadline exceeded, Err returns the reason. That reason will be either context.Canceled, which means someone explicitly called cancel or a parent context was canceled, or context.DeadlineExceeded, which means a timeout or deadline expired.

In practice, Done and Err are often used together. You wait for cancellation by selecting on ctx.Done(), and once that case is triggered, you return ctx.Err() so the caller knows why the operation stopped.

A common mistake is trying to poll ctx.Err() in a loop like this:

for ctx.Err() == nil {
    // do work
}
Enter fullscreen mode Exit fullscreen mode

This can easily become a busy loop and consume unnecessary CPU. Instead, you should block properly using Done inside a select so the goroutine sleeps until cancellation actually happens.

Another subtle point is that Done may return nil for contexts that are never canceled, such as context.Background() or context.TODO(). In a select statement, a case reading from a nil channel is disabled, which means that cancellation logic is effectively ignored for such contexts. This behavior is intentional and part of how root contexts work.

Important thing to remember is that you should always assume that cancellation might have already happened before you start waiting on Done. Context cancellation is asynchronous, so correct code must behave properly whether the context is canceled before or after you begin listening.

6) context.WithValue()

At a basic level, context.WithValue lets you attach a key–value pair to a context:

ctx = context.WithValue(ctx, key, value)
Enter fullscreen mode Exit fullscreen mode

That value becomes accessible to any downstream code that receives the context. This makes it easy to propagate data across API boundaries without modifying every function signature along the way.

However, WithValue exists for a very specific purpose. It is meant to carry request-scoped metadata across process and API boundaries. Typical examples include request IDs, trace IDs, authentication identity or claims, correlation IDs, or locale information. These are pieces of metadata that logically live and die with the request itself.

It is not meant for business data or optional parameters.

The first rule is that values stored in context must be request-scoped. If the value does not logically disappear when the request ends, it does not belong in context. Good examples are request IDs or user identity information extracted from a token. Bad examples include database connections, configuration structs, feature flags, caches, or domain models. If you store those in context, you are misusing it.

The second rule is about keys. Keys must be unexported, unique types to avoid collisions across packages. Using plain strings as keys is a common mistake and can lead to subtle bugs if two packages use the same key name.

Correct pattern:

type requestIDKeyType struct{}

var requestIDKey = requestIDKeyType{}

ctx = context.WithValue(ctx, requestIDKey, "abc-123")
Enter fullscreen mode Exit fullscreen mode

Incorrect pattern:

ctx = context.WithValue(ctx, "requestID", "abc-123") // collision risk
Enter fullscreen mode Exit fullscreen mode

The third rule is that context.WithValue should never be used for required data. If a function cannot operate without some value, that value must be an explicit parameter. Context values are optional by nature. Hiding required dependencies inside context makes the function’s contract unclear.

Bad:

func DoThing(ctx context.Context) {
    user := ctx.Value(userKey).(User)
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Good:

func DoThing(ctx context.Context, user User) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

If user is required for the function to work, it belongs in the function signature.

Another common anti-pattern is using context as a form of dependency injection.

ctx = context.WithValue(ctx, dbKey, db) // very bad
Enter fullscreen mode Exit fullscreen mode

This hides dependencies, breaks static analysis, makes testing harder, and turns context into a generic “bag of stuff.” At that point, you are effectively recreating global state, just in a less obvious way.

7) Context vs Done Channel

Another question that sometimes appears when discussing context during interviews is how it differs from the classic “done channel” pattern that many Go developers used before context.Context became the standard.

A done channel is simply a signaling mechanism used to tell goroutines to stop. The typical pattern looks like this:

done := make(chan struct{})

select {
case <-done:
    return
case v := <-ch:
    _ = v
}
Enter fullscreen mode Exit fullscreen mode

This pattern provides a broadcast signal that something should stop running. Once the done channel is closed, every goroutine waiting on it can exit. That is the entire purpose of the pattern. It provides cancellation signaling, but nothing more.

context.Context, on the other hand, is a standardized and composable cancellation system. It still provides cancellation through the Done() channel, but it also carries additional information and behavior that a simple done channel does not provide.

A context can expose the reason why cancellation happened through Err(). It can carry deadlines and timeouts through Deadline(). It supports cancellation propagation through a hierarchy of parent and child contexts. It can also transport request-scoped metadata through Value(). Because of this richer functionality, the Go ecosystem has standardized around context as the primary way to manage request lifecycles.

This is why most major libraries accept contexts directly. The net/http package, database/sql, gRPC clients, cloud SDKs, and many other tools rely on contexts for cancellation and timeouts.

In practice, the choice between context and a done channel depends on the scope of the problem.

If you are working at an API boundary or across multiple layers of an application, context is the correct tool. It allows cancellation to propagate through service layers, repositories, HTTP clients, database calls, and other external systems. Context is also the right choice whenever you need deadlines, timeouts, or request-scoped metadata.

func callExternalAPI(ctx context.Context) error {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
    if err != nil {
        return err
    }

    _, err = http.DefaultClient.Do(req)
    return err
}
Enter fullscreen mode Exit fullscreen mode

When you are not sure weather to use context or not, always ask yourself if the work is request-scoped and crosses boundaries between components or packages. If so, using context is the idiomatic approach.

Done channels are still useful, but they tend to fit better inside a single component where you control both sides of the communication. They are often used for internal concurrency wiring, simple broadcast signals, or cases where you need custom signals that do not map cleanly to context semantics, such as pause, resume, configuration reload, or draining workers. In these situations you typically do not need deadlines, metadata, or cancellation propagation.

func startWorker(done <-chan struct{}, jobs <-chan int) {
    for {
        select {
        case job := <-jobs:
            process(job)

        case <-done:
            // stop worker
            return
        }
    }
}

func main() {
    done := make(chan struct{})
    jobs := make(chan int)

    go startWorker(done, jobs)
    go startWorker(done, jobs)

    jobs <- 1
    jobs <- 2

    // signal all workers to stop
    close(done)
}
Enter fullscreen mode Exit fullscreen mode

A helpful rule of thumb is that a done channel works well for internal plumbing, while context represents an external contract between components.

In more complex systems it is also common to combine both approaches. A component’s public API may accept a context.Context, while the internal implementation uses channels to coordinate goroutines. In that design, a small bridge connects the two mechanisms. When the context is canceled, the bridge closes an internal channel that signals all internal workers to stop.

func startWorkers(ctx context.Context) {
    done := make(chan struct{})
    jobs := make(chan int)

    // Bridge between context and internal done channel
    go func() {
        <-ctx.Done()
        close(done)
    }()

    // Internal workers use done channel
    for i := 0; i < 3; i++ {
        go worker(done, jobs)
    }

    jobs <- 1
    jobs <- 2
}

func worker(done <-chan struct{}, jobs <-chan int) {
    for {
        select {
        case job := <-jobs:
            process(job)

        case <-done:
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach keeps internal concurrency simple while still integrating cleanly with the broader Go ecosystem that expects context-aware APIs.

8) Common Context Misuse Patterns

There are plenty of traps when it comes to using context in Go. Sneaky interviewers know them all, and they’ll happily wait for you to fall into one. Instead of asking why they’re so mean to us poor developers, let’s sharpen our knowledge and learn these pitfalls properly, so we can answer confidently and get rated at the seniority level we actually deserve.

I can’t really tell you the exact questions an interviewer might ask, because there are countless variations on this topic. Instead, I’ll go over common mistakes in (mis)using context.

The first and most obvious mistake is not using the right context in the right place, not using context at all, or even using context where it’s not necessary.

8.1. Wrong context at the wrong place

A good example of using the wrong context is an endpoint handler that doesn’t derive the context from the request, but instead creates its own.

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()
    // ...
}
Enter fullscreen mode Exit fullscreen mode

This is the wrong approach because, as we already said, context.Background() is a root context that cannot be canceled, and its main purpose is to serve as a base for deriving other child contexts. In an HTTP handler, you already have a request-scoped context available. Ignoring it breaks cancellation propagation. If the client disconnects, your work will continue running for no reason.

The correct approach in this case is to use the context derived from the request.

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Another example of using the wrong context is binding request context to tasks that should be fire-and-forget. What does that mean? It depends on your business logic, but a common example is sending an email. Let’s say you want to send a welcome email when somebody registers for your app. Your handler returns immediately after successful registration, but sending the email is scheduled as a background task. If we bind that task to the request context, as soon as we return from the handler, that background task gets canceled, which we certainly do not want.

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    err := createUser(ctx)
    if err != nil {
        // handle error
        return
    }

    go sendAnEmail(ctx) // this is bad

    w.WriteHeader(http.StatusOK)
}
Enter fullscreen mode Exit fullscreen mode

The problem here is that ctx is tied to the request. Once the handler returns or the client disconnects, that context is canceled, and your email-sending goroutine may stop prematurely. For true fire-and-forget tasks, you need a different context, usually one tied to the application’s lifetime, possibly with its own timeout.

type Server struct {
    baseCtx context.Context // canceled on server shutdown
}

func (s *Server) handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    err := createUser(ctx)
    if err != nil {
        // handle error
        return
    }

    // GOOD: derived from application context, not request context
    go func() {
        ctx, cancel := context.WithTimeout(s.baseCtx, 10*time.Second)
        defer cancel()

        if err := sendWelcomeEmail(ctx); err != nil {
            // handle error
        }
    }()

    w.WriteHeader(http.StatusCreated)
}
Enter fullscreen mode Exit fullscreen mode

8.2. Not calling cancel

Moreover, there is a mistake you might make by not calling cancel() when using context.WithTimeout(). What I mean is this: you might use context.WithTimeout() or context.WithDeadline to bound an operation, which is good practice. What is not good practice is relying on the timeout itself to clean everything up for you. Technically, the timeout will eventually fire and cancel the context, so your code may still “work.” But counting on that behavior instead of explicitly calling cancel() is sloppy. It leaves timers running longer than necessary and delays cleanup. This is not some catastrophic mistake, but these small details are exactly what separate experts from average developers.

When you create a timeout-based context, Go internally allocates a timer and sets up bookkeeping structures that link the parent and child contexts. If your work finishes before the timeout expires and you do not call cancel(), that timer will remain active until it naturally fires. The references between parent and child contexts will also stay in place longer than necessary. By calling cancel(), you stop the timer immediately, break those internal links, and allow the garbage collector to reclaim resources sooner. This is the primary reason for always deferring cancel().

Another important point is that cancellation may happen for reasons other than the timeout itself. The parent context might be canceled, the request might end early, or the server might begin shutting down. Calling cancel() ensures that cleanup happens immediately, regardless of why the work ended. It makes the lifecycle explicit and deterministic.

Failing to call cancel() can also lead to subtle memory and goroutine leaks. Timers may accumulate, context trees may remain referenced longer than they should, and goroutines waiting on Done() may stick around unnecessarily. These are not dramatic crashes; they are slow leaks that degrade a system over time, which makes them particularly dangerous.

The correct usage pattern is simple and should become muscle memory:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

err := doWork(ctx)
_ = err
Enter fullscreen mode Exit fullscreen mode

Even if doWork completes in a few milliseconds, calling cancel() ensures that all associated resources are released immediately rather than waiting for the timeout to expire.

8.3. Not using context at all

And the last mistake to address is not using context-bounded I/O operations. What I mean by that is calling I/O methods that completely ignore the context, even though your function receives on

For example:

func listUsers(ctx context.Context) {
    rows, err := db.Query("SELECT * FROM users") // ❌ ignores context
    if err != nil {
        // handle err
        return
    }
    defer rows.Close()
}
Enter fullscreen mode Exit fullscreen mode

At first glance this might look fine. The function accepts a ctx, so it feels “context-aware.” But in reality, the database call is not using that context at all. If the request is canceled, the client disconnects, or a timeout is exceeded, this query will continue running until the database finishes processing it.

Listening on ctx.Done() somewhere else does not help here. Cancellation in Go is cooperative. If the blocking I/O operation does not receive the context, it cannot react to it.

The correct approach is to use the context-aware version of the API:

func listUsers(ctx context.Context) {
    rows, err := db.QueryContext(ctx, "SELECT * FROM users") // ✅ respects context
    if err != nil {
        // handle err
        return
    }
    defer rows.Close()
}
Enter fullscreen mode Exit fullscreen mode

Now, if the context is canceled or its deadline is exceeded, the database driver can abort the query and return early with context.Canceled or context.DeadlineExceeded.

The same principle applies to HTTP calls. This is wrong:

resp, err := http.Get("https://api.example.com/users") // ❌ ignores context
Enter fullscreen mode Exit fullscreen mode

And this is correct:

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/users", nil)
if err != nil {
    // handle err
    return
}

resp, err := http.DefaultClient.Do(req) // ✅ respects context
Enter fullscreen mode Exit fullscreen mode

If you forget to pass the context into the I/O layer, cancellation simply won’t interrupt the blocking call. Your goroutine may remain stuck waiting on network or database I/O long after the request has been canceled. In high-load systems, this leads to wasted resources, connection pool exhaustion, and subtle performance degradation.

8.4. Causing goroutine leaks

Many goroutine leaks happen in code where developers genuinely believe they are “using context correctly.” The presence of a ctx parameter alone does not guarantee safety. The real issue is whether cancellation is respected at every blocking point.

One obvious bug is not listening on ctx.Done() at all. A goroutine that loops or blocks without checking the context may never exit.

go func() {
    for msg := range ch { // blocks forever if ch never closes
        _ = msg
    }
}()
Enter fullscreen mode Exit fullscreen mode

If ch is never closed and the goroutine does not observe cancellation, it will leak. The fix is to incorporate the context into the blocking point.

go func() {
    for {
        select {
        case msg := <-ch:
            _ = msg
        case <-ctx.Done():
            return
        }
    }
}()
Enter fullscreen mode Exit fullscreen mode

Another very common bug is listening on ctx.Done() but still calling blocking APIs that ignore the context. This gives a false sense of safety.

go func() {
    select {
    case <-ctx.Done():
        return
    default:
        conn.Read() // blocks forever; ctx cannot interrupt it
    }
}()
Enter fullscreen mode Exit fullscreen mode

Here, cancellation will not interrupt conn.Read() unless that operation is context-aware or the connection is closed on cancellation. The fix is to use APIs that accept a context, such as db.QueryContext, http.NewRequestWithContext, or to explicitly close the underlying resource when the context is canceled.

A subtle but important leak source is creating a derived context and forgetting to call cancel().

ctx, _ := context.WithTimeout(parent, time.Second) // forgot cancel
Enter fullscreen mode Exit fullscreen mode

Even if the timeout eventually fires, timers and internal references may live longer than necessary. The correct pattern is:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()
Enter fullscreen mode Exit fullscreen mode

Another dangerous pattern is detaching work from the request by using context.Background() inside an HTTP handler.

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background() // detached from request
    go doWork(ctx)              // may run long after client disconnects
}
Enter fullscreen mode Exit fullscreen mode

This does not always look like a leak at first, but under load it behaves like one. Work continues even after the request is gone, and you lose control over its lifetime. The correct context depends on the business rule: use r.Context() for request-scoped work or a server-owned base context for true background tasks.

Fan-out patterns are another classic source of leaks. Imagine spawning multiple workers that all send results to a single channel.

results := make(chan Result) // unbuffered

for i := 0; i < n; i++ {
    go func() {
        res := work(ctx)
        results <- res // blocks forever if receiver exits on cancel
    }()
}
Enter fullscreen mode Exit fullscreen mode

If the receiver stops reading from results because the context was canceled, the workers can block forever on the send operation. The fix is to make the send cancellation-aware.

go func() {
    res := work(ctx)
    select {
    case results <- res:
    case <-ctx.Done():
        return
    }
}()
Enter fullscreen mode Exit fullscreen mode

Or make the channel buffered if appropriate.

Another leak pattern happens when a goroutine assumes that context cancellation will magically stop a channel receive. If the goroutine blocks on a receive and does not include ctx.Done() in the select, cancellation will not help.

The general rule is simple but powerful: cancellation must be checked at every blocking point. That includes channel receives, channel sends, I/O operations, sleeps, locks, and any long-running loop. If a goroutine can block, it must have a path to observe ctx.Done() and exit cleanly.

That is what separates “we pass context everywhere” from actually writing leak-free concurrent code.

9) Cancelation Causes

Another topic that occasionally appears in more advanced discussions of context is the difference between context.WithCancelCause and the traditional ctx.Err() mechanism. This is a relatively newer addition to Go, and many developers (even experienced ones) are not very familiar with it yet.

In the traditional context model, cancellation only exposes two possible reasons through ctx.Err(): context.Canceled and context.DeadlineExceeded. This is often enough for simple request lifecycles, but it becomes limiting in more complex systems.

Consider a situation where several goroutines are running concurrently. If one goroutine fails with a real error and cancels the shared context, the other goroutines will only observe context.Canceled. The original reason for the cancellation is lost. All you know is that something stopped the work, but you don’t know why.

context.WithCancelCause was introduced to solve this exact problem.

Instead of creating a context with a simple cancel function, you create one that allows you to attach a specific error as the reason for cancellation.

ctx, cancel := context.WithCancelCause(parent)
Enter fullscreen mode Exit fullscreen mode

The cancel function now accepts an error value:

cancel(err)
Enter fullscreen mode Exit fullscreen mode

This error becomes the cause of the cancellation and can later be retrieved using context.Cause.

cause := context.Cause(ctx)
Enter fullscreen mode Exit fullscreen mode

Importantly, this does not change the behavior of ctx.Err(). When a context created with WithCancelCause is canceled, ctx.Err() still returns context.Canceled. The cause is stored separately and must be retrieved explicitly.

A simple example illustrates why this can be useful.

ctx, cancel := context.WithCancelCause(context.Background())

go func() {
    if err := doWork(ctx); err != nil {
        cancel(err) // preserve the real reason
    }
}()

<-ctx.Done()

fmt.Println(ctx.Err())           // context.Canceled
fmt.Println(context.Cause(ctx))  // actual error from doWork
Enter fullscreen mode Exit fullscreen mode

Without cancel causes, all observers would only see context.Canceled, and the original failure would disappear unless it was logged elsewhere. With WithCancelCause, the actual reason for cancellation is preserved and can be inspected later.

The relationship between Err() and Cause() is therefore straightforward. ctx.Err() tells you that cancellation happened, while context.Cause(ctx) tells you why it happened. One represents the control signal, the other represents the diagnostic information.

This feature is particularly useful in systems where many goroutines coordinate through a shared context. Examples include fan-out pipelines, worker pools, supervisors managing background tasks, or situations where one failure should cancel multiple workers. In these cases, preserving the original cause of cancellation can make debugging and observability much easier.

At the same time, cancel causes should not be overused. In many cases, especially simple request handling in HTTP servers, the traditional cancellation semantics are perfectly sufficient. It is also important not to base core business logic on context.Cause. The cause should mainly be used for logging, metrics, debugging, or returning meaningful errors at higher levels of the system.

A useful way to remember the distinction is this: ctx.Err() tells you that cancellation happened, while context.Cause(ctx) tells you why it happened.

10) Simple Questions To Catch You Off Guard

10.1. Multiple context cancellations

Sometimes you will encounter a question about what happens if cancel() is called multiple times on the same cancel function. This question is meant to test whether you understand the correctness and concurrency guarantees of Go’s context cancellation.

Calling cancel() multiple times is completely safe. Cancel functions are intentionally designed to be idempotent, meaning that only the first call has an effect and any subsequent calls simply do nothing.

When cancel() is called for the first time, several things happen. The context’s Done() channel is closed, which broadcasts a cancellation signal to every goroutine waiting on it. The context’s error becomes context.Canceled (or context.DeadlineExceeded if the cancellation was triggered by a deadline). Any child contexts derived from that context are also canceled. In addition, internal cleanup occurs, such as stopping timers and releasing references used to link the context with its parent and children.

If cancel() is called again after that, nothing new happens. The channel is already closed and the cancellation state is already set, so the call effectively becomes a no-op. There is no panic and nothing is “double closed.”

This behavior is intentional because cancel functions are often used in patterns where they may be invoked from multiple code paths. For example, a cancel function is commonly deferred right after it is created, but the same function might also be called earlier if an error occurs.

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

if err := doWork(ctx); err != nil {
    cancel() // safe even though cancel is deferred
    return err
}
Enter fullscreen mode Exit fullscreen mode

Cancel functions may also be called from multiple goroutines coordinating work. If cancelation were not idempotent, developers would need additional synchronization to ensure that the function was only called once. Instead, the context package guarantees that cancel functions are safe to call multiple times and from multiple goroutines.

A useful way to remember this is that cancel functions are both idempotent and concurrency-safe. The first call performs the cancellation and closes the Done() channel, and every subsequent call simply does nothing.

10.2. Context cancellation handling

Handling context cancellation at API boundaries is mostly a matter of understanding what cancellation actually represents. Context cancellation is not an error in the traditional sense. It is a control signal that tells your code the work is no longer needed.

Two common cancellation-related errors are context.Canceled and context.DeadlineExceeded. These occur frequently in normal operation. A client might close their browser tab, a load balancer might cut the connection, a request might exceed its deadline, or the server might begin shutting down. In all of these cases the cancellation simply indicates that continuing the work would be pointless.

Because of that, these conditions usually should not be logged as errors. Logging them as errors tends to produce large amounts of noise in production logs and can hide real problems. Instead, the usual approach is either to ignore them or optionally record them at a debug level if you want additional observability.

For example:

if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
    return
}
Enter fullscreen mode Exit fullscreen mode

Another important consideration is how these errors propagate through layers of an application. In lower layers such as repositories, services, or workers, the correct behavior is usually to return the context error upward. The caller can then decide how to interpret it.

return ctx.Err()
Enter fullscreen mode Exit fullscreen mode

At API boundaries such as HTTP handlers, the handling is slightly different. Cancellation errors typically should not be returned as application-level failures. If the client has already disconnected, there may be no point in writing a response at all. Instead, the handler should stop work and exit quietly.

if errors.Is(err, context.Canceled) {
    return
}
Enter fullscreen mode Exit fullscreen mode

For deadlines, some systems may return a 408 Request Timeout response if the connection is still open, but often the framework or infrastructure layer already handles this behavior.

It is also important to understand that “ignoring” cancellation does not mean pretending it never happened. The correct response is to stop work immediately, avoid retrying the operation, avoid wrapping the error as something else, and avoid treating it as a failure condition.

There are also cases where cancellation information can still be useful operationally. A high rate of request cancellations may indicate slow downstream dependencies, poor timeout configuration, or system overload. In those situations it is often helpful to track cancellation counts through metrics or occasionally log them at a debug level. Even then, they should still not be treated as application errors.

A useful way to think about it is that context.Canceled and context.DeadlineExceeded are signals that the work is no longer needed, not indicators that something is broken.

10.3. Why is context always first parameter ?

Another question that sometimes appears in interviews is why the convention in Go is to place ctx context.Context as the first parameter in a function signature. This might look like a small stylistic detail, but it actually reflects an important design principle.

The main reason is that a context defines the lifetime and scope of the entire operation. Whether the work should continue, stop early, or time out depends entirely on the context. By placing it first in the function signature, the code clearly communicates that the operation is governed by that context.

For example:

func FetchUser(ctx context.Context, id string) (User, error)
Enter fullscreen mode Exit fullscreen mode

This reads naturally: “fetch a user under this context.” The context determines whether the operation should continue running, whether a deadline applies, and whether request-scoped metadata should be available.

Another practical reason for placing the context first is consistency across the Go ecosystem. The standard library, database drivers, HTTP clients, gRPC, and most third-party libraries all follow the same convention. Because of this, developers quickly learn to recognize and propagate contexts in a consistent way. When every function that supports cancellation places ctx first, code becomes easier to read, review, and refactor.

Putting the context first also makes it harder to ignore or misuse. When a function signature begins with ctx, the caller immediately knows that the operation supports cancellation and deadlines and that they should pass the existing context instead of creating a new one. It also makes mistakes easier to notice during code review.

For example:

DoWork(ctx, id)
Enter fullscreen mode Exit fullscreen mode

is immediately clear, while something like:

DoWork(id, ctx)
Enter fullscreen mode Exit fullscreen mode

is easier to misread or accidentally reorder.

Finally, this convention makes mechanical propagation of context much simpler. Middleware, wrappers, generated code, and helper functions can forward the context without needing to know the rest of the parameters. Because the position is always the same, tools and developers can rely on a predictable pattern.

10.4. When a function does not need to take context as a parameter ?

A function does not need to take context.Context if it does not do anything that is long-running or blocking, cancelable, tied to an external request or work lifetime, or calling other context-aware APIs where it should forward the context. In other words, if passing a context into the function would not change anything meaningful about how the function behaves, then the context is just noise.

A classic example is a pure function. If a function is deterministic, does no I/O, and cannot realistically block, then it has nothing to gain from cancellation or deadlines. A password hashing helper that just takes a string and returns a string, an email validator, or a simple totals calculator are all good examples of functions that should not accept a context, because there is no useful lifetime to control.

A context is also often unnecessary for small helper functions that are simply implementation details inside a larger operation. If a helper is only used within a function that already has a context, it does not automatically mean the helper should take the context too. Unless that helper blocks or needs to support cancellation, adding ctx to its signature can make the code heavier without any benefit.

Similarly, functions that are guaranteed to complete quickly and do not call context-aware APIs usually do not need a context. Formatting, mapping, copying, and simple transformations are typically not improved by context propagation.

Methods on data structures that represent local, in-memory operations also typically do not need context. A cache Get method or an in-memory store Add method does not usually block, does not do I/O, and is not naturally cancelable, so forcing ctx into these APIs is often unnecessary. Of course, if those methods do block or wait internally, then context becomes relevant, but for purely in-memory operations it is usually not.

A good interview summary is that a function should take context if it might block, might be canceled, or needs to propagate cancellation and deadlines to downstream operations. If none of those apply, leave it out.

10.5. If parent context is canceled, what happens if child context has longer timeout ?

If a parent context is canceled, all of its child contexts are canceled immediately, even if a child has a later deadline, a longer timeout, or even its own cancel function. So yes, the parent context wins.

This is because contexts form a tree, and cancellation always propagates downward through that tree. It never propagates upward and it never bypasses the parent. That enforces a strict rule: a child context cannot outlive its parent. If that were possible, work could continue after its owner has stopped caring, request lifetimes would be violated, and shutdown semantics would become unreliable.

Internally, when the parent is canceled, the parent’s Done() channel is closed, all children are notified, each child’s Err() becomes context.Canceled, and any timers associated with child timeouts are stopped. At that point, the child’s own deadline becomes irrelevant because the context is already canceled.

Here is a small example that shows it clearly:

parent, cancelParent := context.WithCancel(context.Background())
child, cancelChild := context.WithTimeout(parent, 10*time.Second)
defer cancelChild()

cancelParent()

<-child.Done()
fmt.Println(child.Err()) // context canceled
Enter fullscreen mode Exit fullscreen mode

Even though the child context was created with a 10-second timeout, canceling the parent cancels the child immediately.

A common interview trap is when someone says “the earlier deadline wins.” That is not the right rule for this situation. The correct rule is that the closest canceled ancestor wins. The mental model to remember is that context lifetimes shrink as you go down the tree, never expand.

11) Senior Level Questions

11.1. Designing graceful shutdown with context

One of the more advanced context-related questions you might encounter is how to design a graceful shutdown mechanism for a Go HTTP server. This question ties together several ideas we already discussed: context ownership, cancellation propagation, timeouts, and correct handling of long-running work.

The goal of graceful shutdown is to stop the service cleanly without interrupting work unnecessarily. When the process receives a shutdown signal such as SIGINT or SIGTERM, the server should stop accepting new connections, allow in-flight requests to finish within a bounded time, cancel any background work owned by the service, and exit without leaving goroutines running.

A common pattern is to create a server-owned base context that represents the lifetime of the service itself. Any background tasks that belong to the server should derive from this context so they can be canceled during shutdown.

baseCtx, baseCancel := context.WithCancel(context.Background())
defer baseCancel()
Enter fullscreen mode Exit fullscreen mode

This base context effectively means “the service is alive.” When the server begins shutting down, canceling this context signals all service-scoped work to stop.

Next, start the HTTP server. The http.Server type allows you to specify a BaseContext function so that every request context derives from your service context.

srv := &http.Server{
    Addr: ":8080",
    BaseContext: func(net.Listener) context.Context {
        return baseCtx
    },
}

go func() {
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()
Enter fullscreen mode Exit fullscreen mode

Using BaseContext ensures that when the server shuts down, request contexts are also canceled in addition to client disconnects.

The next step is to wait for an operating system signal that indicates the process should terminate.

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

<-sigCh
Enter fullscreen mode Exit fullscreen mode

Once the signal arrives, the shutdown process begins. First cancel the service-owned context so any background goroutines know they should stop.

baseCancel()
Enter fullscreen mode Exit fullscreen mode

Then create a bounded shutdown context so the server does not wait forever for requests to finish.

shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
Enter fullscreen mode Exit fullscreen mode

Finally call the server’s graceful shutdown method.

if err := srv.Shutdown(shutdownCtx); err != nil {
    _ = srv.Close()
}
Enter fullscreen mode Exit fullscreen mode

Shutdown stops accepting new connections, closes idle connections, and waits for active handlers to return. If the shutdown timeout expires before all requests finish, calling Close() forces the remaining connections to terminate.

A minimal example that puts everything together looks like this:

func main() {
    baseCtx, baseCancel := context.WithCancel(context.Background())
    defer baseCancel()

    srv := &http.Server{
        Addr: ":8080",
        BaseContext: func(net.Listener) context.Context {
            return baseCtx
        },
    }

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    <-sigCh

    baseCancel()

    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer shutdownCancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        _ = srv.Close()
    }
}
Enter fullscreen mode Exit fullscreen mode

Interviewers asking this question are usually checking a few specific things. They want to see that you distinguish between request-scoped work and service-scoped work, that you understand the difference between Shutdown and Close, that you always bound shutdown time using a timeout, and that you treat http.ErrServerClosed as a normal part of shutdown rather than an error.

11.2. What happens when a client disconnects during a response?

Another subtle case appears when an HTTP handler is streaming data or writing a large response. In this situation the client may disconnect before the server finishes sending the response.

When that happens, the request context is canceled. The channel returned by r.Context().Done() is closed and r.Context().Err() becomes context.Canceled. This is the server’s signal that continuing the work is pointless.

Handlers that stream data should therefore regularly check the request context and stop producing output when cancellation occurs.

ctx := r.Context()

for {
    select {
    case <-ctx.Done():
        return
    default:
    }

    chunk, ok := nextChunk()
    if !ok {
        return
    }

    if _, err := w.Write(chunk); err != nil {
        return
    }

    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    }
}
Enter fullscreen mode Exit fullscreen mode

Another thing to expect is that Write operations may fail once the connection is broken. Errors such as “broken pipe” or “connection reset by peer” are common in this situation. Your code should treat these as normal termination conditions rather than unexpected failures.

It is also important that any downstream operations involved in producing the response use the same context. If generating response chunks involves database queries, external HTTP calls, or other I/O operations, those calls should receive the same context so they stop when the client disconnects.

One subtle detail is that context cancellation does not magically stop every operation. If your code blocks inside a library that does not support context, a channel receive without a select, or a long CPU loop with no checks, the goroutine may continue running even after the client disconnects. Streaming handlers should therefore avoid blocking operations that cannot observe cancellation.

11.3. Common mistakes when setting timeouts

Timeouts are an important part of context usage in real services, but they are also a common source of subtle bugs.

One of the biggest mistakes is simply not using timeouts at all. If outbound calls to databases, APIs, or other services have no deadlines, requests can hang indefinitely during partial outages. Over time this leads to goroutine accumulation, memory growth, and request queues backing up.

A different class of problems comes from stacking unrelated timeouts across different layers of the system. For example, a load balancer might have a 30-second timeout, a middleware might enforce 10 seconds, an HTTP client might use 2 seconds, and a database query might use 1 second. If these limits are not designed intentionally, requests can fail unpredictably and debugging becomes difficult.

Another common mistake is choosing arbitrary round numbers like five or thirty seconds without considering the total request budget. A better approach is to work backward from the overall request deadline and allocate smaller budgets for individual dependencies.

For example, if a request should complete within two seconds, a database query might receive a 300-millisecond budget and an external API call might receive 700 milliseconds, leaving time for processing and response serialization.

Developers also sometimes forget to call cancel() when using context.WithTimeout. Even though the timeout will eventually fire, not calling cancel() delays cleanup and can leave timers active longer than necessary.

Finally, timeouts are sometimes misused as a workaround for deeper problems such as goroutine leaks or blocking operations that ignore context. A timeout cannot fix code that blocks forever on a channel send, receive, or non-context-aware I/O call. Correct cancellation wiring must still exist.

11.4. Per-dependency timeout budgets

In real systems a single request may call several dependencies such as databases, caches, or external APIs. Each dependency should have its own timeout, but none of them should exceed the overall request deadline.

A common approach is to calculate a per-dependency budget based on the remaining time in the request context. The effective timeout becomes the smaller of the dependency’s maximum allowed time and the remaining request time.

func withBudget(parent context.Context, depMax time.Duration, headroom time.Duration) (context.Context, context.CancelFunc) {
    if deadline, ok := parent.Deadline(); ok {
        remaining := time.Until(deadline) - headroom
        if remaining <= 0 {
            return context.WithTimeout(parent, 0)
        }
        if remaining < depMax {
            return context.WithTimeout(parent, remaining)
        }
    }
    return context.WithTimeout(parent, depMax)
}
Enter fullscreen mode Exit fullscreen mode

Each dependency call then derives its own context using this helper.

dbCtx, cancel := withBudget(ctx, 250*time.Millisecond, 50*time.Millisecond)
defer cancel()
queryDB(dbCtx)
Enter fullscreen mode Exit fullscreen mode

This ensures that no dependency consumes the entire request budget and that all operations still respect the overall deadline.

11.5. Testing code that uses context

Testing context-aware code requires care because naive approaches often lead to slow or flaky tests.

The most reliable strategy is to use context.WithCancel and cancel the context explicitly. This allows tests to verify that goroutines stop correctly without relying on real timeouts.

func TestWorkerStopsOnCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())

    stopped := make(chan struct{})

    go func() {
        defer close(stopped)
        worker(ctx)
    }()

    cancel()

    select {
    case <-stopped:
    case <-time.After(200 * time.Millisecond):
        t.Fatal("worker did not stop after cancel")
    }
}
Enter fullscreen mode Exit fullscreen mode

In this pattern the timeout is only a safety guard for the test itself rather than part of the logic being tested.

Another useful technique is synchronizing goroutines with channels so tests know exactly when certain code paths have been reached instead of relying on time.Sleep.

For code that performs deadline calculations, injecting a clock or a now() function can make tests deterministic without depending on real wall-clock time.

11.6. Context anti-patterns and performance considerations

The final advanced topic involves common context anti-patterns and performance implications.

One of the most frequent mistakes is using context as a general-purpose container for application state. Storing dependencies such as database handles, configuration structs, or large objects inside context values hides dependencies and makes code harder to understand and test.

ctx = context.WithValue(ctx, "db", db) // ❌ bad practice
Enter fullscreen mode Exit fullscreen mode

Another problem is using string keys for context.WithValue. This can lead to collisions between packages and subtle bugs.

ctx = context.WithValue(ctx, "requestID", "abc-123") // ❌ collision risk
Enter fullscreen mode Exit fullscreen mode

Typed, unexported keys should always be used instead.

type requestIDKeyType struct{}

var requestIDKey = requestIDKeyType{}

ctx = context.WithValue(ctx, requestIDKey, "abc-123")
Enter fullscreen mode Exit fullscreen mode

Developers sometimes also create new contexts deep inside call stacks using context.Background() or arbitrary timeouts. Doing this breaks cancellation propagation and can cause unexpected behavior because the new context is detached from its parent.

func queryUsers(ctx context.Context) ([]User, error) {
    // ❌ wrong: ignores parent cancellation
    ctx = context.Background()

    rows, err := db.QueryContext(ctx, "SELECT * FROM users")
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    return readUsers(rows), nil
}
Enter fullscreen mode Exit fullscreen mode

Ignoring cancellation at blocking points is another common source of problems. Goroutines that block on channel operations or I/O without observing the context may continue running long after the request has been canceled.

From a performance perspective, passing a context is very cheap, but creating new derived contexts is not free. Each call to WithCancel, WithTimeout, or WithValue allocates objects and may create timers. Excessive use of these functions inside hot loops can lead to unnecessary overhead.

Additionally, ctx.Value() performs a lookup through the chain of parent contexts. Deep context chains with many values can increase lookup cost in critical paths.

The key takeaway is that context should be created at logical boundaries, passed through the call chain, and used sparingly for metadata and cancellation.

12) Conclusion

Thats it. Now you have full understanding of context in Go and there is no question on this topic that might come up in an interview and catch you off guard. Please note that I write all of my texts myself, but I do use AI here and there to help with formatting and polishing the text. After all, I am a software developer, not a content writer and I intended my articles for article readers, not mind readers.

Good luck with interviews.

Top comments (0)