---
title: 'Timeouts and Deadlines'
source: 'https://academia.sh/en/courses/go-concurrency/timeouts-and-deadlines'
course: Concurrency
language: en
updated: '2026-09-14T10:59:31+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Shows, with two deterministic setups and without measuring duration, that a timeout reduces to which branch is chosen in a select statement'
    - 'Proves that a cancelled context and a ready result lead to different branches in the same select structure'
    - 'Shows by measuring the leak as a boolean that a timeout rescues the waiter but does not stop the work goroutine that keeps running behind it'
---

# Timeouts and Deadlines

A timeout is a branch choice in a select statement: if a result is already ready, the result branch is chosen; if the context is already cancelled (representing a timeout), the cancellation branch is chosen — both deterministic, because only one branch in the setup is actually ready. Duration is never measured anywhere, the only thing printed is which branch was chosen. Limiting measurement: a timeout rescues the waiting side but does not stop the awaited work — a work goroutine whose result no one will take anymore is left behind, as a leak, behind the caller who exited its branch.

The previous lesson measured how a context's cancellation reaches waiters **after** that
cancellation had already happened — `cancel()` was called explicitly, and the waiters were
waiting for that call. But in real systems, the most common cause of cancellation is not a
person's decision, it is **a duration running out**: waiting for a network call to respond within
a reasonable time, and giving up if it does not. This lesson measures a timeout — but not as a
duration. A timeout reduces, in fact, to one of two branches of a `select` statement being
chosen: did a result arrive, or did a cancellation arrive. This lesson builds, without ever
measuring duration, which branch gets chosen, in a **deterministic** way.

The context package offers two separate constructors for this: `context.WithTimeout` builds a
context that cancels after a **relative** duration from now; `context.WithDeadline` states an
**absolute** point on the calendar (a deadline) and cancels once that point is reached. Both
arrive at the same result — a `Done()` channel that closes on its own at a certain point — and as
far as `select` is concerned, neither is distinguishable in any way from the previous lesson's
`context.WithCancel`. This lesson therefore does not build the two separately; it models the
common point both arrive at — "the context has closed at some point" — and never prints a real
duration or a real calendar value at all.

## A Branch Choice: A Result, or a Cancellation

The measurement below places two setups side by side. In the first, the result is **already
ready**: by the time `select` runs, a value is already waiting on the result channel, and the
context has not been cancelled. In the second, the context is **already cancelled** —
representing a timeout — and the result channel is empty, with no sender at all. In both setups,
which branch `select` will choose is known in advance, because in the setup itself only **one**
branch is really ready.

```go
// branchchoice.go — a timeout is a branch choice; what is measured is not the duration but which branch is chosen
package main

import (
	"context"
	"fmt"
)

func chooseBranch(ctx context.Context, result <-chan string) string {
	select {
	case value := <-result:
		return "result: " + value
	case <-ctx.Done():
		return "cancelled: " + ctx.Err().Error()
	}
}

func main() {
	fmt.Println("-- setup 1: result already ready --")
	ctxReady, cancelReady := context.WithCancel(context.Background())
	defer cancelReady()
	resultReady := make(chan string, 1)
	resultReady <- "completed"
	fmt.Println("chosen branch:", chooseBranch(ctxReady, resultReady))

	fmt.Println()
	fmt.Println("-- setup 2: context already cancelled (represents a timeout) --")
	ctxCancelled, cancel := context.WithCancel(context.Background())
	cancel()
	resultEmpty := make(chan string)
	fmt.Println("chosen branch:", chooseBranch(ctxCancelled, resultEmpty))
}
```

```
-- setup 1: result already ready --
chosen branch: result: completed

-- setup 2: context already cancelled (represents a timeout) --
chosen branch: cancelled: context canceled
```

**CP19.** In both setups, the moment `chooseBranch` is called, only **one** branch stands ready
in front of `select` — this is the guarantee that the result was not chosen at random. In setup
1, `resultReady` is a buffered channel and a value was placed into it **before** `select` runs;
`ctxReady` has not been cancelled, meaning `ctx.Done()` never closed at all. `select`, in a
situation where only one of two branches is ready, always chooses that branch — the choice here
depends not on chance but on the setup itself. Setup 2 is the mirror image: `ctxCancelled` was
cancelled before `chooseBranch` is called, while `resultEmpty` is completely empty with no
goroutine writing to it at all — the `ctx.Done()` branch is the only one ready here.

These two setups were deliberately placed at the extremes: in a real race, both branches can
become ready **at the same time** (if the result lands at exactly the moment of the timeout),
and in that case `select` decides which one to choose at random — this is the same randomness
rule established in the `select` Statement lesson. But this lesson never builds that common
point at all: both setups are situations where only one branch is ready, and the other definitely
is not. Determinism comes from exactly here — the measurement chooses the two corners where
**no** randomness exists, and never runs the middle ground where randomness could occur at all.

**CP20.** A real timeout is built with `context.WithTimeout`, and the context cancels **on its
own** after a certain duration — the setups this lesson measures **model** that real timing,
without ever waiting for a real duration to pass at all. `ctxCancelled` in setup 2 being "already
cancelled" produces the same result as "a timed-out context whose duration has run out": as far
as `select` is concerned, the visible face of both is the same, because `select` does not know
**why** a context was cancelled, it only sees whether the `Done()` channel is closed or not. This
is what makes the measurement as valid as a real timeout — the only difference is **when** the
cancellation was triggered, not in `select`'s own behavior.

Duration never appears in any line here: no `time.Sleep`, no `time.After`, no measured delay. The
only thing measured is **which branch was chosen** — this is the direct application of the
previous lesson's "not duration, the chosen branch is written" rule. Why establishing order with
`time.Sleep` is forbidden is visible here once again: if this measurement were built with a real
timeout duration, which branch would win would depend on the machine's load at that moment —
modeling removes this uncertainty entirely.

The `chooseBranch` function itself is **the same** in both setup 1 and setup 2 — no branching, no
special-case code at all. This is the most practical side of the timeout arrangement: making a
function responsive to a timeout does not require adding an "if the duration has run out"
check inside that function; it is enough just to add the cancellation branch into a `select`, and
the rest is already handled by the context, whose own closing responsibility is already built.
The same `chooseBranch` serves its caller equally well whether it receives a cancellation from
`WithCancel` or a timeout from `WithTimeout`, with no change needed at all.

## A Timeout Rescues the Waiter, It Does Not Stop the Awaited Work

Once the `chooseBranch` call above returns, the calling side is **rescued** — it is no longer
waiting, it can move on with a result or a cancellation text. But this rescue does not mean that
the work expected to produce the result has stopped too. The measurement below builds on setup 2:
**after** the caller exits the cancellation branch, a work goroutine that is actually producing
the result starts and tries to send its result — but there is no one left to take it anymore.

```go
// leak.go — a timeout rescues the waiter but does not stop the awaited work
package main

import (
	"context"
	"fmt"
	"runtime"
)

func chooseBranch(ctx context.Context, result <-chan string) string {
	select {
	case value := <-result:
		return "result: " + value
	case <-ctx.Done():
		return "cancelled: " + ctx.Err().Error()
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	cancel() // represents a timeout: the caller has already given up

	result := make(chan string) // unbuffered, no sender yet

	fmt.Println("-- caller exits its branch --")
	fmt.Println("chosen branch:", chooseBranch(ctx, result))

	// The caller left. To model the work as if it is still running, the work
	// goroutine now starts and tries to write its result to the same channel.
	// It never checks the context, so it keeps going, but there is no receiver left.
	base := runtime.NumGoroutine()
	go func() {
		result <- "late result"
	}()

	fmt.Printf("is the work goroutine still running (leak): %v\n", runtime.NumGoroutine() > base)
}
```

```
-- caller exits its branch --
chosen branch: cancelled: context canceled
is the work goroutine still running (leak): true
```

**CP21.** When `chooseBranch` is called, no goroutine has yet written to the `result` channel —
this is why the chosen branch is definitively `ctx.Done()`. The work goroutine only starts
**after** `chooseBranch` returns; this ordering is deliberate, because the goal is to remove
entirely the question of whether the work goroutine existed **during** the `select` from being a
matter of timing. Once the work goroutine starts, it tries to send its own result to the `result`
channel, but the channel is unbuffered and there is no one left reading it at all —
`chooseBranch` has already returned, the main goroutine is no longer listening to `result`. The
send is permanently blocked.

**CP22.** The result `runtime.NumGoroutine() > base` comes out `true` — this is the boolean leak
measure seen repeatedly in the previous lessons, appearing here for the third time, but for a new
reason: in the worker pool the cause was closing being forgotten, in fan-out it was
responsibility never being given to anyone, in the pipeline it was the work never finishing.
Here the cause is entirely different — there is not even a channel to close, the problem is that
the work goroutine **has no idea at all who is listening**. `chooseBranch` choosing the
`ctx.Done()` branch tells the work goroutine nothing at all; the work goroutine keeps doing its
own share of the computation and keeps trying to send its result, exactly like the previous
lesson's negligent goroutine finishing its work without ever checking cancellation.

That `base` is captured a second time in this measurement, right **before** the work goroutine
starts, is also worth noting: the first two setups' own goroutines (if any) may have already
finished, and the measurement needs to focus only on the single goroutine in the third setup.
This is the same discipline as the `runtime.NumGoroutine()` usages in the previous lessons: the
baseline is always taken right before the goroutine to be measured starts, so the comparison
reflects only whether that one goroutine exists or not — the count of other goroutines that have
been running since the program started never gets mixed in.

This explains why the lesson's two title words ("timeout" and "resource leak") are mentioned
together. A timeout **rescues** the caller from a waiting state — the caller is no longer blocked
forever, it can move on with a response (a cancellation or a result). But what a timeout rescues
is only the **waiting side**; the work that was expected to produce the result is entirely
unaware of the timeout, and keeps running at its own pace. When the caller says "I've given up,"
this news never reaches the work goroutine at all — just like the previous lesson, the context
does not force anyone to stop, it only opens an exit door for the waiting side.

The work goroutine in this measurement was deliberately built in its plainest form: a single
send, no computation at all. In a real system, this goroutine would be a network request, a
database query, or a file read — work with its own duration, wanting to complete without being
interrupted. A timeout does not stop that work itself; it only lets the **caller** give up
waiting for that work. Whether the work actually completes in the background is not this
measurement's subject — what is certain here is only that the work's result will **no longer be
taken by anyone**, because the unbuffered channel's sole reader has already left.

The practical consequence of this is clear: setting up a timeout alone is not enough. The work
goroutine itself must also **share** the same context and check its own `ctx.Done()` — otherwise,
while the caller says "I timed out" and moves on, the work left behind can keep running forever.
This lesson does not build this second step, it only measures what happens when it is not built;
having the work goroutine take the same `ctx` and check it in its own `select` would be a direct
repeat of the pattern seen in the previous two lessons (adding a `ctx.Done()` branch to a stage).

This topic's five lessons measured five separate faces of the same problem, and all of them meet
at the same point: unless a goroutine checks its own `ctx.Done()` or its own closing channel,
no matter how well-intentioned a mechanism is set up, no signal coming from outside can stop it.
In the worker pool, this check was the `for job := range jobs` loop itself; in the pipeline, it
was every stage's own input channel; here, it is the missing `select` branch the work goroutine
never wrote. All five repeat the same lesson from a different angle: stopping depends on a
goroutine reaching a check point in its own code path; with no check point, no outside signal
carries any meaning at all.

## Summary

- A timeout is a branch choice in a `select` statement; duration is never measured, only which
  branch was chosen is written down.
- If a result is already ready, or if the context is already cancelled, the branch `select`
  chooses is deterministic — because only one branch in the setup is really ready.
- A cancelled context's appearance, as far as `select` is concerned, is the same as a timed-out
  context whose duration has run out; `select` does not know the reason for cancellation, it only
  sees whether `Done()` is closed or not.
- A timeout rescues the waiting side but does not stop the work expected to produce the result;
  the work goroutine keeps running as long as it does not check its own context.
- This last leak, too, is measured as a boolean; its cause differs from the previous lessons'
  three causes (forgotten closing, ownerless responsibility, unfinished work) — a sender with no
  idea who is listening.
- `context.WithTimeout` (a relative duration) and `context.WithDeadline` (an absolute point) are
  indistinguishable from `context.WithCancel` as far as `select` is concerned; all three give a
  `Done()` channel that closes on its own.

## Next Step

Every mechanism built across this topic's five lessons — the worker pool, fan-out and fan-in, the
pipeline, the context, the timeout — rested on the same principle: goroutines notified each other
through **communication**, by sending on a channel or by closing one. A timeout rescues the
waiter but does not stop the awaited work; the next topic turns to shared state — what changes
once, instead of communication, the choice is to protect a common value. There, goroutines no
longer notify each other over a channel; they limit their access to the same memory with a lock —
and the decision to stop still depends on a step taken by whoever is holding that lock.
