1. Why concurrent code needs timeouts, and why Sleep doesn’t save you
When you first write concurrent code, it usually looks cheerful: “start a goroutine, wait for a result from a channel.” And that’s all true—until something unexpected happens in the real world. But in real programs, the “unexpected” is not a rare exception; it’s the default bundle: computation took too long, the sender never started, the receiver went away, the queue overflowed, or it just “happened that way.”
In Go, a timeout is not magic and not a special channel setting. A timeout is just one more event that can happen in parallel with your send/receive. So we don’t “set a timeout on a channel”; we add one more case to select: “either the data arrived, or time is up.” This pattern is one of the classics of Go concurrency.
Why not time.Sleep? Because Sleep can’t compete with other events. It simply “freezes” the current goroutine for a fixed duration. If the data arrives earlier, Sleep will still sleep until the end. But what we need is: “wait, but not longer than N.”
Mental model: a timeout is a signal channel
Before writing code, let’s lock in a simple model. select can choose between channel operations. That means a timeout should look like a channel where “something will arrive” after N time.
You can imagine it like this:
flowchart TD
A[We want to wait for a result from ch] --> B{select}
B -->|case v := <-ch| C[Got the result]
B -->|case <-timeoutCh| D[Time is up: timeout]
An important psychological point: a timeout is not an “accelerator” and not a “killer” of other goroutines. A timeout only says: “we stop waiting here.” If a goroutine is running somewhere in parallel, it will keep running unless you provide a separate stop protocol.
2. time.After(d): the simplest timeout for waiting
time.After(d) returns <-chan time.Time. The meaning of that channel is simple: “after d time, a time value will be sent to the channel.” Usually we don’t care about the actual timestamp—we care that the event happened.
Let’s start with a micro-example: “receive from a channel, but don’t wait forever.”
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
select {
case v := <-ch:
fmt.Println("got:", v)
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout") // timeout
}
}
This code will almost certainly print timeout, because we never send anything into ch. And that’s exactly the useful demonstration: without a timeout, the program would hang forever on <-ch, but with it we get a “second exit.”
Small nuance: creating a timeout
time.After(100 * time.Millisecond) creates a timer every time you call the function. That’s fine for infrequent operations and learning examples. But if you call time.After in a very hot loop a thousand times per second, that’s a reason to think about time.NewTimer. For now, just keep in mind: After is “convenient,” NewTimer is “control.”
4. Timeout for receive: wait for a result no longer than N
Now let’s make an example a bit closer to real life: there’s a goroutine that computes something and sends a result. Sometimes it “makes it,” sometimes it “doesn’t.”
We’ll start building a small learning app. Let it be a console “delay lab”—a program that starts work in a goroutine and waits for an answer no longer than a given duration. Let’s name it modestly: timeout-lab.
Step 1: a function that “does work” and sends the result
package main
import (
"time"
)
func startWork(delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- "work done"
}()
return out
}
Here we made out a buffer of 1. Why? So the goroutine can “drop the result and leave,” even if the receiver has already stopped waiting. This is an important protection against sender deadlocks: the buffer allows the goroutine to send and finish even if nobody reads.
Step 2: wait for the result with a timeout
package main
import (
"fmt"
"time"
)
func main() {
resultCh := startWork(150 * time.Millisecond)
select {
case msg := <-resultCh:
fmt.Println(msg) // work done
case <-time.After(100 * time.Millisecond):
fmt.Println("timeout while waiting result") // timeout while waiting result
}
}
If the work delay is 150ms and the timeout is 100ms, we’ll exit via the timeout. If you change it to startWork(50 * time.Millisecond), you’ll more often see work done.
The key idea: a timeout is just another branch of select.
5. Timeout for send: try to send, but don’t hang
A timeout is useful not only for reading. Sending to a channel can also block:
- on an unbuffered channel, sending waits for a reader;
- on a buffered channel, sending waits for free space if the buffer is full.
If you have a protocol like “we send the result into a channel,” at some point it may turn out that the receiver is no longer reading (for example, it already left due to a timeout). Then the sender may hang on ch <- v.
The “send or timeout” pattern looks the same; only the operation in the case changes.
package main
import (
"fmt"
"time"
)
func main() {
out := make(chan int) // unbuffered
select {
case out <- 1:
fmt.Println("sent") // (most likely we won't see this)
case <-time.After(50 * time.Millisecond):
fmt.Println("timeout on send") // timeout on send
}
}
Since there is no reader, out <- 1 is not ready, and after 50ms the timeout will fire.
6. Turning it into a neat function: recvWithTimeout
When you see the same select { case <-ch: ... case <-time.After: ... } repeated 10 times, your brain starts getting quietly sad. So a typical next step is to extract the timeout into a function.
Let’s make a function that waits for a string from a channel for no longer than d and returns (string, bool), where bool means “we made it in time.”
package main
import "time"
func recvWithTimeout(ch <-chan string, d time.Duration) (string, bool) {
select {
case v := <-ch:
return v, true
case <-time.After(d):
return "", false
}
}
And usage:
package main
import (
"fmt"
"time"
)
func main() {
ch := startWork(80 * time.Millisecond)
msg, ok := recvWithTimeout(ch, 100*time.Millisecond)
if ok {
fmt.Println("OK:", msg) // OK: work done
return
}
fmt.Println("timeout") // (if the work takes longer than the timeout)
}
Notice the style: ok here is not an “error,” but specifically an “in time / not in time” flag. We intentionally don’t introduce complex error handling yet—today the goal is to learn the timeout pattern.
7. When you need time.NewTimer instead of time.After
When time.After starts being inconvenient
time.After is great as long as:
- you use it rarely;
- you don’t want to manage the timer.
But there are situations where you want to say: “if we got the result earlier, then we don’t need the timer anymore.” With time.After you can’t stop the timer, because you don’t hold it “in your hands”: you only get a channel.
In most learning tasks this is not critical. But in concurrent designs with frequent timeouts or inside loops, it can be useful to have a controllable timer.
A timer as an object: time.NewTimer(d)
time.NewTimer(d) returns *time.Timer. The timer has a channel C you can read to receive the firing signal. The main difference: you can stop the timer via Stop().
Basic template:
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case <-timer.C:
fmt.Println("timeout") // timeout
}
}
So far this looks like the same thing as time.After. But the power of NewTimer shows up when we have a competing event, such as a work result.
Example: got the result—timer is no longer needed
package main
import (
"fmt"
"time"
)
func main() {
resultCh := startWork(50 * time.Millisecond)
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case msg := <-resultCh:
fmt.Println(msg) // work done
case <-timer.C:
fmt.Println("timeout")
}
}
When the result arrives in 50ms, we exit the select. The timer still exists, but defer timer.Stop() says: “if the timer hasn’t fired yet—stop it.” This is a more disciplined style when you want to explicitly show: “the timer is a resource; I stop it.”
Important: Stop() has nuances (for example, what to do if the timer has already fired). Here we won’t dive into the deep weeds of draining the timer channel—we care about the principle: NewTimer gives you control.
8. Mini behavior map: After vs NewTimer
Let’s make a small table so things don’t get mixed up in your head.
| Tool | Returns | Convenience | Control | Typical scenario |
|---|---|---|---|---|
|
|
maximum | minimum | “quick and simple: add a timeout to select” |
|
|
a bit more complex | can Stop() | “frequent timeouts; need to manage the timer” |
And once more in plain language: After is like a one-shot alarm clock—“it will ring in 5 minutes,” while NewTimer is an alarm clock with a “cancel” button.
9. Embedding timeouts into timeout-lab: a more lively example
Now let’s assemble a small complete example of a program you can run and get different behavior just by changing delays. It’s a good exercise for your brain: you start to feel when which case is ready.
package main
import (
"fmt"
"time"
)
func startWork(name string, delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- name + ": done"
}()
return out
}
func main() {
ch := startWork("job#1", 120*time.Millisecond)
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
select {
case msg := <-ch:
fmt.Println("RESULT:", msg)
case <-timer.C:
fmt.Println("RESULT: timeout") // RESULT: timeout
}
}
Play it out mentally: if delay is less than 100ms, it will print RESULT: job#1: done. If it’s more, it will time out.
Why is out buffered? So that even if main has already exited due to timeout and finished, the goroutine itself won’t get stuck on out <- .... It’s like “dropping a letter into a mailbox” instead of waiting at the door until the recipient opens.
10. A timeout is not cancellation of work
A common beginner trap: “I set a timeout, so the work stopped.” No. A timeout only says that at this point you stopped waiting.
If your goroutine is doing something long-running, it will keep doing it even if you timed out. Sometimes that’s acceptable (for example, “let it finish, we don’t need the result anymore”), but sometimes it’s not (for example, “we start thousands of operations and want to stop the extra ones”).
To “stop the extra ones,” you usually need a separate completion signal. In Go, ctx.Done() often plays that role; it’s also read like a channel in select. But we’ll stay disciplined here: today we focus specifically on timeouts with After/NewTimer as a basic technique and remember that a timeout does not replace a stop protocol.
11. Common mistakes
Error No. 1: using time.Sleep instead of a timeout in select.
It’s very tempting to write “I’ll wait 100ms and then check the channel,” but Sleep doesn’t compete with other events: it just blocks the goroutine. As a result, you either waste time (the data arrived earlier, but you’re still sleeping), or you get a choppy “woke up → checked → slept again” logic that quickly turns into a poorly managed loop.
Error No. 2: calling time.After in a tight loop and being surprised by load and allocations.
time.After is convenient, but it creates a timer. If you call it very often, you create a lot of timers. In learning examples this is fine, but in “hot” code paths it’s better to switch to time.NewTimer, where you explicitly manage the timer lifecycle and can stop it.
Error No. 3: thinking a timeout “stops” another goroutine.
A timeout ends the wait only where you handle it. If you have a goroutine that must be able to stop, it needs a separate exit path: closing the input channel, a separate done signal, or cancellation via context (the ctx.Done() signal is also read in select). Without that, a timeout can lead to “we left, but the goroutine stayed.”
Error No. 4: forgetting the risk of the sender hanging and using an unbuffered channel for the result.
If the receiver can leave due to a timeout, the sender can hang forever on ch <- result if the channel is unbuffered and nobody is reading. One simple way to protect yourself is a buffer of 1 on the result channel: “drop it and exit.”
Error No. 5: using time.NewTimer but not stopping it at all.
A timer is a runtime resource. In simple cases it will “live” until it fires and everything will be ok. But if you want a clean style—and especially if you have many timers—it’s better to follow the discipline: timer := time.NewTimer(d) and defer timer.Stop(). Then the code reads like “created a resource—guaranteed cleanup.”
GO TO FULL VERSION