1. Backpressure: what it is
When you’re just starting to write concurrent code, you really want to make it so that “nothing ever blocks.” It feels like blocking is a bug, and non-blocking execution is a sign of being cool. In Go it’s a bit trickier: blocking on sending to a channel is often a useful signal that stabilizes the system and prevents it from ballooning in memory.
Backpressure (literally “reverse pressure”) is a situation where the producer (producer) is forced to slow down because the consumers (workers/collector) can’t keep up. In Go this is most often expressed very simply: sending to a channel blocks because the channel is full (if it’s buffered) or because there is no receiver (if it’s unbuffered). And that’s normal: the program “self-regulates” this way.
Imagine a factory conveyor belt. If the packers can’t keep up, the conveyor has to slow down; otherwise boxes start falling on the floor, and then the accountant shows up and says: “Why is our warehouse now the size of a cosmodrome?” That’s what backpressure is — “the conveyor slows down by itself.”
Channel buffer as a queue: cap(ch) is the limit
A Go channel can be thought of as a pipe, but for understanding memory it’s more useful to think of it as a queue. If the channel is buffered, it can store some number of elements “inside itself.” And that number is not an abstraction, but a very concrete value: cap(ch).
It’s important to fix this right away: the channel buffer stores values. If you send Task into the channel, then the buffer holds Task. If you send *Task, then the buffer holds pointers. And this directly affects memory: the buffer keeps these values “alive,” and the garbage collector will not be able to free what they reference.
A mini-demo to feel len/cap with your hands:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
fmt.Println(len(ch), cap(ch)) // 2 2
// ch <- 30 // would block here: buffer is full
}
Here cap(ch) is “how much we can accumulate,” and len(ch) is “how much we have accumulated right now.” Yes, len(ch) changes “on the fly” in a concurrent program and should not be used as a control condition, but as a diagnostic thermometer it’s useful: it shows where a bottleneck forms.
2. Why a large buffer can eat memory
Often a developer sees blocking and thinks: “Oh, I should add a bigger buffer — and it will get faster.” Sometimes it will. And sometimes you just move the problem into memory: instead of blocking, the producer starts accumulating data in the queue, and the program “politely” dies from OOM (out of memory), letting you enjoy the absence of blocking.
There are three mechanisms by which buffers can suddenly become memory eaters.
-
Straightforward mechanism: cap(jobs) * size(Task). If a task is heavy (for example, it contains a large string, []byte, or references a large object), then even a queue of 10_000 tasks is already significant.
-
Holding the backing array via “lightweight headers”: even if you put a small “header” into the channel, for example []byte (which is small by itself), it contains a pointer to the backing array. That is, the channel buffer holds the reference, and therefore holds all the backing array memory as well.
This is similar to the slice story where an “invisible tail” can retain memory. In the standard library, they even had to refine functions like slices.Delete so they clear the tail; otherwise old references prevented the garbage collector from freeing objects. With channels, the logic is similar: as long as the value sits in the queue, the memory “won’t be released.”
-
The queue may be not in the channel, but in goroutines: if you do go func(){ ... acquire sem ... }(), then goroutines can accumulate by the thousands and wait for a token. Concurrency seems limited, but memory still grows — because the queue is hidden not in the channel, but in the scheduler.
4. Educational mini-system: a task pipeline and a “warehouse”
So we don’t discuss backpressure in a vacuum, let’s continue our overall example style: we have a pipeline “producer → workers → collector.” Imagine that in our educational app (roughly: a task tracker / batch task processor) a “mass processing” mode appeared, where we need to apply the same kind of operation to a large list of input items.
Let’s start with the types. They are as simple as possible, but already allow us to talk about memory meaningfully:
package main
type Task struct {
ID int
Payload string // imagine this can be a "heavy" string
}
type Result struct {
ID int
Size int
Err error
}
Now the worker. It does “work” — here it just computes the string length, but in a real task it could be parsing, validation, compression, etc.
package main
import "sync"
func worker(jobs <-chan Task, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for t := range jobs {
results <- Result{ID: t.ID, Size: len(t.Payload)}
}
}
Notice: here results <- ... can potentially block the worker. This is the backpressure point between worker and collector. If the collector reads slowly, workers start waiting, which means they stop pulling new tasks. And that can be both bad (throughput drop) and good (the system limits itself and doesn’t inflate the queue further).
5. Practice: where to buffer and how not to eat memory
Where to place the buffer: jobs, results, and moving the bottleneck
A beginner usually asks: “So do we need a buffer or not?” The correct answer is: “A buffer is not about ‘need/don’t need’ — it’s about where and what size.”
If jobs is unbuffered, the producer will be very tightly coupled to the workers: send a task — someone must accept it immediately. This creates strong backpressure upstream in the pipeline: the producer can’t “run ahead.”
If jobs is buffered, the producer can “pour tasks into the queue” and move on. This increases worker utilization (less idle time), but creates a task warehouse in memory.
If results is unbuffered, workers are tightly coupled to the collector. If output/aggregation is slow, workers will be idle. If results is buffered, workers can send a few results “ahead,” but the results buffer is also a warehouse and also consumes memory.
Here’s a visual scheme where buffers are warehouses:
flowchart LR P[producer] -->|send| J[(jobs buffer)] J --> W[workers x N] W -->|send| R[(results buffer)] R --> C[collector]
The most important takeaway is this: a buffer doesn’t remove a bottleneck, it moves it. The bottleneck either becomes blocking when the buffer fills, or turns into memory growth. This isn’t “bad,” it’s just system physics.
Bounded queue and deliberate sizing
Now to the practical part: what to do if there are many tasks (tens of thousands, millions), but you want to keep memory under control.
The main technique is to make the task queue bounded, i.e., limited. For a worker pool this usually means: jobs := make(chan Task, queueSize), where queueSize is not “infinity,” but a reasonable value.
An example of a “reasonable warehouse”: suppose the queue holds 100 tasks, and there are 4 workers. Then the maximum number of tasks that are simultaneously “alive in the system” (and not yet processed) is roughly equal to the queue plus what the workers are holding right now. The producer can’t fly into space and pour in a million tasks, because on the 101st task it blocks and waits.
package main
import "sync"
func run(workers, queueSize int, tasks []Task) []Result {
jobs := make(chan Task, queueSize)
results := make(chan Result, queueSize)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go worker(jobs, results, &wg)
}
go func() {
wg.Wait()
close(results)
}()
go func() {
defer close(jobs)
for _, t := range tasks {
jobs <- t // backpressure: if the queue is full, the producer waits
}
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out
}
Here it’s intentionally done so that queueSize affects both jobs and results. In reality the sizes may differ, but for a first mental model this is good: we limited both the “task warehouse” and the “results warehouse.”
Now an anti-example — something that looks like “speeding up,” but at scale turns into “memory, goodbye”:
package main
func badIdea(tasks []Task) chan Task {
jobs := make(chan Task, 1_000_000) // sounds "cool", but it's a huge warehouse
for _, t := range tasks {
jobs <- t
}
return jobs
}
The problem isn’t even the number 1_000_000 itself. The problem is that it’s an unjustified warehouse, and you lost backpressure. If Task is heavy, you literally asked the program to keep “a million heavy objects” in memory, even if the workers can only process 100 per second.
A buffer of 1: “so it doesn’t hang”
There is a separate class of situations where a small buffer is not about performance, but about correct shutdown behavior.
Classic example: you have a goroutine that tries to send a signal on timeout, and it doesn’t matter whether anyone read that signal. Then the channel is often made buffered with 1 so the goroutine doesn’t hang on send and can finish. This is exactly the logic why in “first error” patterns the error channel is often make(chan error, 1): we want to guarantee that the first error “fits,” and the worker won’t hang if the reader isn’t ready yet.
Here’s a very small illustration from our topic. Suppose the collector wants to “take the first error” and then stop the system. The errCh channel is often made with a buffer of 1 so that the sender doesn’t hang:
package main
import "errors"
var ErrBoom = errors.New("boom")
func reportFirst(errCh chan<- error, err error) {
if err == nil {
return
}
select {
case errCh <- err:
default:
// error is already recorded; don't block
}
}
There is an important balance here: we use a buffer (and select + default) not to “speed things up,” but to avoid getting a stuck goroutine. But if you start “not blocking” everywhere in the same way, you can accidentally turn the system into one that “drops data,” and then spend a long time figuring out where results went.
How to choose a buffer size without guessing
You really want a universal formula: “set queueSize = workers * 10 and be happy.” Sometimes people do that, but it’s important to understand the meaning.
Buffer size is a trade-off between three things: memory, latency, and worker utilization. A small buffer increases backpressure: the producer waits more often, but the queue doesn’t grow and memory stays calm. A large buffer smooths out bursts: the producer can quickly “pour” data, workers don’t idle, but latency grows (a task can sit in the queue for a long time), and memory grows.
It’s convenient to think about it like this:
| Parameter | If you increase cap(jobs) | If you decrease cap(jobs) |
|---|---|---|
| Memory | the task warehouse grows | the task warehouse shrinks |
| Latency (task wait time) | may increase (longer queue) | usually a shorter queue |
| Backpressure | weaker (producer waits less) | stronger (producer waits more often) |
| Resilience to spikes | higher | lower |
And one more point that’s important to say explicitly: backpressure is not a punishment, it’s a resilience mechanism. When you have large data volumes, you almost always want the system to be able to tell the producer: “Stop, I can’t keep up, wait.” If the producer keeps “pushing” without limits, you won’t speed up processing — you’ll just move the data from input into your program’s memory.
6. Typical mistakes
Mistake #1: making a huge buffer “so it never blocks.”
This looks like an optimization, but at large volumes it often turns into a “warehouse queue” that keeps data in memory for hours. As a result, you gain a couple of milliseconds on send, but lose the whole process due to memory growth and long queues.
Mistake #2: thinking a buffered channel is “always faster.”
The buffer often just moves the place where you block: instead of blocking immediately on jobs <- t, you block later, when the buffer fills up. If consumers are slower than the producer, you can’t cheat physics — you only choose where to accumulate.
Mistake #3: not understanding that a channel retains memory through references.
If the queue contains []byte, string, map, or a pointer to a struct, then the buffer holds these references, and therefore holds the backing array/objects until the value is read. This is very similar to the slice situation and the “tail” that retains objects and prevents the garbage collector from freeing them.
Mistake #4: trying to control concurrent logic via len(ch).
len(ch) is useful in debugging and logs, but as a control condition in a concurrent program it almost always leads to races and weird hangs. Pipeline completion is usually expressed via a channel-closing protocol (close) and waiting for workers (WaitGroup), not by “when len becomes 0.”
Mistake #5: “the collector got tired and left,” but workers keep writing to results.
If the consumer stops reading, workers may hang on send. Sometimes this is “fixed” with a buffer, but a buffer only postpones the problem. A more reliable approach is a clear stop protocol (usually via context), where workers respect ctx.Done() and have a path to shut down correctly instead of hitting a blocking send.
GO TO FULL VERSION