Goroutines and Channels Explained with Examples
I explain Go's goroutine model, show how channels handle synchronization, and cover buffered channels, the select statement, and WaitGroup with code and JS comparisons.

If you’ve built anything with async/await in JavaScript, you have a working mental model for concurrent code. Goroutines look approachable from that angle — they’re cheap, they run functions in the background, and the syntax is minimal. But the model underneath is structurally different, and transplanting JS intuition directly into Go causes real problems before long.
The clearest example: JavaScript’s concurrency is cooperative. A Promise yields back to the event loop. Goroutines are multiplexed across OS threads by the Go runtime — they can run in parallel on different cores, and they don’t ask your permission. The Go documentation captures the design intent in one line: “Do not communicate by sharing memory; instead, share memory by communicating.”
I’m going to walk through goroutines, channels, the buffered vs unbuffered distinction, the select statement, and sync.WaitGroup — with working code and comparisons to patterns you already know from JavaScript.
On this page
- Goroutines are multiplexed, not cooperative
- Channels: typed pipes with built-in synchronization
- Buffered vs unbuffered: who blocks and when
- The select statement: waiting on multiple channels
- WaitGroup: knowing when all goroutines finish
- Mistakes that catch JS developers off guard
- Treat goroutines as pipeline stages, not event handlers
Goroutines are multiplexed, not cooperative
The go keyword is the only syntax you need to start a goroutine. Prefix any function call with it and the Go scheduler runs that function concurrently with the rest of your program.
func main() {
go printNumbers() // scheduled on a goroutine; main goroutine continues
printLetters() // runs on the main goroutine
}
The key word in that comment is “scheduled,” not “spawned.” A goroutine isn’t a thread. As the Go documentation explains, goroutines are “multiplexed onto multiple OS threads so if one should block, such as while waiting for I/O, others continue to run.” The runtime manages this mapping. Goroutine stacks start small and grow by allocating heap storage as required, which is why you can create thousands without hitting a memory ceiling.
The contrast with JavaScript is direct. A Promise represents a future value — an object holding a pending computation. A goroutine is an independently executing function. It doesn’t carry a result by itself. If you need to pass data back to the caller, you use a channel.
One more thing worth noting: when the main goroutine returns, the program exits. Any goroutines still running are silently terminated. If you start background goroutines and main finishes before they do, their work is lost. That’s the first place synchronization becomes necessary.
Channels: typed pipes with built-in synchronization
A channel is a typed conduit for communicating between goroutines. You create one with make:
ch := make(chan int) // unbuffered channel of integers
done := make(chan bool) // commonly used to signal completion
Sending a value writes to the right side of <-: ch <- 42. Receiving reads from the left side: v := <-ch. Here’s the idiomatic pattern — start a goroutine that does work, receive the result through a channel:
func main() {
ch := make(chan string)
go func() {
result := compute()
ch <- result // sends result; goroutine exits after this
}()
output := <-ch // main goroutine blocks here until the value arrives
fmt.Println(output)
}
This replaces the callback or .then() you’d use in JavaScript. Instead of compute().then(r => use(r)), you start a goroutine and receive from the channel when you’re ready.
Close a channel once no more values will be sent: close(ch). You can detect closure with the two-value receive form:
value, ok := <-ch
if !ok {
// channel is closed and fully drained
}
A for range loop reads from a channel until it closes and drains — no index, just values:
for v := range ch {
process(v) // runs once per value; exits when ch is closed
}
Buffered vs unbuffered: who blocks and when
This is where Go concurrency surprises most developers, and where subtle bugs hide.
An unbuffered channel (the default) requires sender and receiver to be ready at the same moment. The sender blocks until the receiver picks up the value; the receiver blocks until the sender delivers one. Both parties have to show up at the channel simultaneously.
ch := make(chan int)
go func() {
ch <- 100 // blocks here until someone receives
}()
v := <-ch // blocks here until the goroutine sends
fmt.Println(v) // 100
A buffered channel has a queue. The sender deposits values up to the capacity without waiting for a receiver. It only blocks when the buffer is full.
ch := make(chan int, 3) // buffer holds up to 3 values
ch <- 10 // returns immediately — room in buffer
ch <- 20 // returns immediately
ch <- 30 // returns immediately
ch <- 40 // blocks — buffer is full, no receiver running
Before you continue: If you create an unbuffered channel and send to it on the main goroutine without starting a receiver goroutine first, what happens?
The program deadlocks. The main goroutine waits for a receiver that never comes. The Go runtime detects this and crashes with fatal error: all goroutines are asleep - deadlock!. Buffered channels can delay this mistake, which is part of what makes goroutine leaks harder to spot — the program keeps running, it just quietly accumulates stuck goroutines.
Check this before moving on
- You can explain why unbuffered channels require simultaneous sender and receiver readiness
- You know that receiving from a closed, empty channel returns the zero value and
false - You understand that a goroutine blocked on a channel operation it can never complete will never be garbage collected
The select statement: waiting on multiple channels
select lets a goroutine wait on multiple channel operations at once. It runs whichever case is ready first. When multiple cases are ready at the same time, Go picks one at random — not the first one listed.
select {
case msg := <-requests:
handle(msg)
case err := <-errors:
log.Println("error from worker:", err)
case <-time.After(2 * time.Second):
log.Println("timeout — no activity for 2s")
}
time.After returns a channel that receives a value after the specified duration. Pairing it with select is the idiomatic timeout pattern in Go.
Add a default case to make select non-blocking:
select {
case v := <-ch:
fmt.Println("received:", v)
default:
fmt.Println("nothing ready right now")
}
If you’ve used Promise.race in JavaScript, the mental model is similar — act on whichever channel delivers first. The difference is that Go’s version is synchronous within the goroutine, the types are explicit, and you’re not creating new Promise objects in the process.
The select statement is also how Go implements cancellation. A done channel is passed through a pipeline; when the pipeline needs to stop, closing done causes every select in the pipeline to unblock from case <-done:. The Go Blog’s pipeline article shows this pattern in full.
WaitGroup: knowing when all goroutines finish
The most common coordination need is: start N goroutines, wait for all of them to finish. sync.WaitGroup handles this precisely. The counter starts at zero; Add(n) increments it, Done() decrements it, and Wait() blocks until it hits zero again.
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // increment before launching
go func(n int) {
defer wg.Done() // decrement when this goroutine exits
processItem(n)
}(i)
}
wg.Wait() // blocks until all five goroutines have called Done
fmt.Println("all done")
Three rules that matter in practice:
- Call
wg.Add(1)before thegostatement, never inside the goroutine. If the goroutine runs and exits beforeAddis called, the counter misses the completion andWaitreturns too early. - Use
defer wg.Done()so the counter decrements even if the goroutine panics or returns through an early path. - Never copy a
WaitGroupafter first use — pass a pointer if you’re passing it to another function.
As of Go 1.25, sync.WaitGroup has a Go method that combines Add(1) and the goroutine launch: wg.Go(func() { processItem(n) }). If you’re on Go 1.25 or later, that form is cleaner and removes the ordering mistake from rule 1.
Promise.all is the closest JavaScript equivalent — both wait for a collection of concurrent tasks. The difference is that WaitGroup has no built-in result channel; if you need the results, pair it with a shared slice or a separate results channel.
Mistakes that catch JS developers off guard
Goroutine leak. If you start a goroutine that blocks sending to a channel but nothing ever reads from it, that goroutine is stuck forever. It won’t be garbage collected — goroutines must exit on their own. In a server, this builds up silently. The fix is always a paired receiver, or a select with a done channel that lets the goroutine exit early.
Sending on a closed channel panics. Receiving from a closed channel is safe — you get the zero value and false. Sending to a closed channel panics immediately with send on closed channel. The rule: only the sender should close a channel, and only close it once. If multiple goroutines send to the same channel, don’t close it from any of them — use a WaitGroup to wait for all senders to finish, then close from outside.
Sharing state instead of communicating. It’s tempting to share a variable between goroutines with a plain assignment. Without synchronization — a mutex, an atomic operation, or a channel — that’s a data race. The Go race detector (go run -race ./...) catches most of these at runtime. Run it during development, not just in production.
Passing a loop variable by reference before Go 1.22. In Go versions before 1.22, the loop variable i was shared across all goroutine closures in a for loop. Every goroutine captured the same pointer and likely read the same final value of i. Go 1.22 changed loop variable semantics so each iteration gets its own variable. If you’re on an older version, pass i as a function argument (as in the WaitGroup example above) rather than capturing it.
Treat goroutines as pipeline stages, not event handlers
The mental model that sticks is pipelines. Each goroutine does one thing: read from an input channel, transform the data, write to an output channel. The channels between goroutines are the coordination boundary — typed, synchronised, and explicit.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
Each function returns a receive-only channel (<-chan int). The caller connects stages by passing one function’s output into the next. Fan out to more workers by launching more goroutines on the same input channel. Add a buffer to reduce back-pressure at a slow stage. Cancel the whole pipeline by closing a done channel. None of these changes require touching every stage in the chain.
This is structurally different from building callback chains or Promise sequences. In a Promise chain, the shape of the program is determined at the call site. In a Go pipeline, the shape is determined by which channels you connect — and each stage is an ordinary function that doesn’t know whether it’s being used standalone or inside a larger chain.
If you’re coming from JavaScript and want to see exactly where the two models diverge, the posts on the Node.js event loop and JavaScript Promises and async/await are useful background.