Skip to content
gohacks.dev
Concurrency

Context WithTimeout vs WithDeadline in Go Compared

Compare context.WithTimeout and WithDeadline in Go: the same mechanism, how to choose, how deadlines nest, and how to share one budget across calls.

8 min read
Summarize in AI
Flat illustration of two options weighed side by side.

The two constructors look different and are the same. context.WithTimeout takes a duration and context.WithDeadline takes a time.Time, and the standard library implements the first as a one-line call to the second. Which one you write is a question about what you know when you write it: a length of time, or a moment. The difference shows up in how a budget is shared across steps, so this article compares them on the cases where it matters.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

It follows context explained and belongs to the concurrency material. Everything ran on Go 1.25.6.

They are one mechanism

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	parent := context.Background()

	a, cancelA := context.WithTimeout(parent, 200*time.Millisecond)
	defer cancelA()

	b, cancelB := context.WithDeadline(parent, time.Now().Add(200*time.Millisecond))
	defer cancelB()

	da, _ := a.Deadline()
	db, _ := b.Deadline()
	fmt.Println("deadlines within a millisecond of each other:", db.Sub(da).Abs() < time.Millisecond)

	<-a.Done()
	<-b.Done()
	fmt.Println(a.Err(), "|", b.Err())
}
Text
deadlines within a millisecond of each other: true
context deadline exceeded | context deadline exceeded

Both contexts expire about 200 ms after creation and report context deadline exceeded. WithTimeout(parent, d) is defined as WithDeadline(parent, time.Now().Add(d)), so there is no behavioral difference between them beyond when time.Now() is read. The time package's monotonic clock is used for the comparison, so changes to the wall clock (NTP corrections, daylight saving) do not make a deadline fire early or late.

When to prefer each

Use WithTimeout for a limit on a single operation measured from now: "this HTTP call may take 2 seconds", "this query may run for 500 ms". The duration is the natural unit, the code is shorter, and each call gets a fresh budget.

Use WithDeadline when the end of the budget is an instant that already exists:

  • A deadline arrived from the caller (a gRPC deadline, a header with an absolute time), and you pass it along.
  • You compute one deadline for a multi-step operation, and every step shares it.
  • You want a retry loop to stop at a fixed time and not to restart a timeout on each attempt.

The retry loop is where the choice causes bugs. Consider three attempts, each with a 1-second timeout, for an operation that should take at most 2 seconds in total. If every attempt does WithTimeout(ctx, time.Second) the worst case is three seconds, and the operation overruns its budget. If the operation derives one deadline and every attempt shares it, the total is bounded:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func attempt(ctx context.Context) error {
	select {
	case <-time.After(60 * time.Millisecond): // every attempt takes 60ms and fails
		return errors.New("transient failure")
	case <-ctx.Done():
		return ctx.Err()
	}
}

func perAttemptTimeouts() (int, time.Duration) {
	start := time.Now()
	n := 0
	for i := 0; i < 5; i++ {
		ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
		n++
		err := attempt(ctx)
		cancel()
		if err == nil {
			break
		}
	}
	return n, time.Since(start)
}

func sharedDeadline() (int, time.Duration) {
	start := time.Now()
	ctx, cancel := context.WithDeadline(context.Background(), start.Add(200*time.Millisecond))
	defer cancel()
	n := 0
	for i := 0; i < 5; i++ {
		n++
		if err := attempt(ctx); err == nil {
			break
		} else if ctx.Err() != nil {
			break
		}
	}
	return n, time.Since(start)
}

func main() {
	n, d := perAttemptTimeouts()
	fmt.Printf("per-attempt timeouts: %d attempts, about %v\n", n, d.Round(50*time.Millisecond))
	n, d = sharedDeadline()
	fmt.Printf("shared deadline:      %d attempts, about %v\n", n, d.Round(50*time.Millisecond))
}
Text
per-attempt timeouts: 5 attempts, about 300ms
shared deadline:      4 attempts, about 200ms

With per-attempt timeouts that never expire (each attempt fails in 60 ms, under its 100 ms limit), all five attempts run, about 300 ms in total, regardless of any overall budget. The shared deadline of 200 ms stops the loop after four attempts (the fourth is cut off partway), at the fixed instant, about 200 ms in total. This is the practical meaning of "an absolute deadline": it is a budget for the whole operation. Production retry loops usually need both: a shared deadline for the whole thing and a per-attempt timeout inside it (retry with backoff).

Deadlines only get shorter

A context's effective deadline is the earliest deadline along its chain. A child may set a longer deadline, and it will have no effect, because the parent's cancellation propagates down:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	parent, cancelP := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancelP()

	child, cancelC := context.WithTimeout(parent, 10*time.Second)
	defer cancelC()

	start := time.Now()
	<-child.Done()
	fmt.Println("child ended after about", time.Since(start).Round(50*time.Millisecond), "not 10s:", child.Err())

	cd, _ := child.Deadline()
	pd, _ := parent.Deadline()
	fmt.Println("child reports the parent's deadline:", cd.Equal(pd))
}
Text
child ended after about 50ms not 10s: context deadline exceeded
child reports the parent's deadline: true

The child asks for ten seconds and gets fifty milliseconds. When a child is asked for a deadline later than its parent's, WithDeadline returns a context that inherits the parent's deadline, and Deadline() reports the earlier one. This is the property that makes a deadline set at the edge of a service safe: no inner layer can accidentally extend it, however generous its own timeouts are.

Using the remaining budget

ctx.Deadline() tells you when the context will expire, which makes budget arithmetic possible:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

// callWithShare gives one of n remaining calls an equal share of the time left.
func callWithShare(ctx context.Context, remainingCalls int) (context.Context, context.CancelFunc) {
	dl, ok := ctx.Deadline()
	if !ok {
		return context.WithCancel(ctx) // no overall budget: no share to compute
	}
	left := time.Until(dl)
	return context.WithTimeout(ctx, left/time.Duration(remainingCalls))
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()

	for calls := 3; calls > 0; calls-- {
		c, done := callWithShare(ctx, calls)
		dl, _ := c.Deadline()
		fmt.Printf("%d call(s) left: this call gets about %v\n", calls, time.Until(dl).Round(25*time.Millisecond))
		done()
		time.Sleep(20 * time.Millisecond) // work that used some of the budget
	}
}
Text
3 call(s) left: this call gets about 100ms
2 call(s) left: this call gets about 150ms
1 call(s) left: this call gets about 250ms

Each call receives an equal share of what remains, so earlier calls that finish quickly leave more for later ones. Other policies are as easy: reserve a fixed amount for a final step, or skip an optional call if less than some minimum remains (if time.Until(dl) < 50*time.Millisecond { skip }), which avoids starting work that cannot finish. A function that checks the deadline up front and fails fast with a clear error is better than one that starts and times out in the middle (cancellation in web services shows the request-wide version).

Reasons for a timeout, in the error

WithTimeoutCause and WithDeadlineCause (Go 1.21) attach an error that context.Cause returns when the deadline fires, so you can say which budget expired:

main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeoutCause(context.Background(), 20*time.Millisecond,
		errors.New("billing call exceeded its 20ms budget"))
	defer cancel()

	<-ctx.Done()
	fmt.Println("Err:  ", ctx.Err())
	fmt.Println("Cause:", context.Cause(ctx))
}
Text
Err:   context deadline exceeded
Cause: billing call exceeded its 20ms budget

Err() still reports context deadline exceeded, so existing checks work, while Cause names the specific budget. With several nested timeouts this turns "something timed out" into "the billing call timed out" (context cause).

Absolute deadlines across services

The most important use of an absolute deadline is across a service boundary. A request arrives with a budget (an HTTP header carrying a deadline, or the gRPC metadata that carries one automatically), and the service calls two others. If it passes each call a fresh WithTimeout(ctx, 2*time.Second), the three layers can each spend two seconds, and the caller who allowed three has long gone. If it passes the same deadline down, and each layer subtracts a small margin, the whole call tree stays inside the original budget. In gRPC this is built in; for HTTP you carry it yourself in a header and apply it in middleware with WithDeadline, as the web service article shows. Clock skew between machines makes absolute timestamps a poor thing to send as wall-clock time, so propagate the remaining duration in the header and convert it to a deadline on arrival with WithTimeout. The two constructors end up cooperating: durations travel between machines, and deadlines are used within one.

Side-by-side

WithTimeoutWithDeadline
Argumenta durationan absolute time
Starts countingwhen calledat the given instant
Natural useper-call limitshared budget, propagated deadline
Equivalent toWithDeadline(parent, time.Now().Add(d))the primitive
Can extend a parent's deadlinenono
Needs defer cancel()yesyes
Retry loopsrestarts the clock each attemptbounds the total

Common mistakes

Restarting the timeout on each retry and overrunning the budget, as shown. Not calling cancel. A timeout context releases its timer on expiry, but until then (and if the work finishes early) the cancel function frees it sooner, and go vet flags the omission. Setting a timeout in a library function that the caller's context already bounds, which adds a second, hidden limit that surprises the caller; accept the caller's deadline and let them choose. Using a zero or negative duration, which creates a context that is already expired: WithTimeout(ctx, 0) returns a context whose Done is closed immediately, a useful property when a computed remaining time has run out and a confusing one when the value is a bug. Comparing a deadline to the wall clock with time.Now() arithmetic by hand. Use time.Until(dl), which uses the monotonic reading.

The reference is the context.WithDeadline documentation, which states that it is the earlier of the parent's deadline and the one given, and the WithTimeout entry, which gives the equivalence to WithDeadline.

Taggedcontext
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.