skip to content

In Go, why does appending incoming events to a slice instead of a bounded channel remove backpressure?

level: juniorimportance: should knowfreq 48%

answer

  1. append never says no
  2. who tells the producer to slow down?
  3. the rate gap has to go somewhere
  4. waiting versus bytes
  5. a finite buffer is the feedback

basics

~20 s

Appending to a slice never blocks, so nothing ever slows the producer down. A bounded channel makes the sender wait once the buffer is full; a slice just grows, so the backlog turns into heap until the process runs out of memory.

solid answer

~50 s

Backpressure is the slow consumer's slowness reaching the producer, and in Go a send on a channel with a bounded buffer is the construct that provides it: once the buffer is full the sending goroutine parks until a receiver takes something. `append` has no such state. It grows the backing array and returns, so the read loop keeps pulling events at full rate no matter how far behind the workers are. The rate gap does not disappear; it is just recorded in memory instead of in waiting, so live heap grows roughly as the integral of arrival rate minus drain rate until the process is killed. A bounded channel also gives you a memory ceiling you can compute (`cap` times item size) and a place to observe or shed. An unbounded staging slice gives you neither.

code

go · 9 lines
go
dec := json.NewDecoder(r)
var pending []Event // grows without limit
for {
	var e Event
	if err := dec.Decode(&e); err != nil {
		return err
	}
	pending = append(pending, e) // always succeeds: no feedback to the reader
}

go deeper

for a junior

Be ready to say that a channel send can block and a slice append cannot, and that this is the whole difference. Name the consequence out loud: the producer keeps full speed and memory grows.

for a middle

Explain the mechanics: append reallocates and returns, a full bounded channel parks the sending goroutine, and a parked reader stops pulling from the source so the stall reaches upstream. Be able to compute the memory ceiling a bounded intake gives you.

for a senior

Show that you recognise the production shape — constant input, unsaturated CPU, live heap rising after every collection — and that you would fix it by bounding the intake rather than by raising the memory limit.

for a principal

Own the trade you are making: bounding converts a memory failure into upstream latency, and somebody has to agree that waiting is preferable to dropping for this data. Make the ceiling and its consequences explicit in the design, not implicit in a slice.

## What backpressure means inside a Go process Backpressure is the property that a consumer which cannot keep up eventually forces the producer to slow down. It is a *feedback* mechanism: the slowness has to travel backwards along the flow of data. In a single Go process there is essentially one built-in construct that provides it — a send on a channel created with a bounded buffer, `make(chan Event, n)`. When `n` items are already queued and nobody is receiving, the next `events <- e` parks the sending goroutine. That parked goroutine is the feedback. ## Why `append` cannot push back `pending = append(pending, e)` has no failure mode and no waiting state. If the backing array is full, `append` allocates a larger one, copies, and returns a new slice header. From the producer's point of view every call succeeds immediately. So a read loop like `for { dec.Decode(&e); pending = append(pending, e) }` runs at the speed of the source — the network, the file, the upstream producer — completely independent of how fast the workers drain `pending`. The mismatch between arrival rate and drain rate does not go away when you remove the bound. It changes representation. With a bounded channel it shows up as *time*: producers wait. With an unbounded slice it shows up as *bytes*: the heap grows. Time is recoverable; bytes are not, because a process has a hard ceiling and nothing reclaims events that have not been processed yet — they are live, reachable memory, not garbage. ## What it looks like in production A streaming ingestion service reading events from an `io.Reader` behaves perfectly in a load test that stops after a minute. Under sustained real traffic the shape is distinctive: input rate is constant, CPU is not saturated, no goroutine is blocked, and yet live heap after every garbage collection is higher than after the previous one. Latency of unrelated work rises because the collector is doing more work over a bigger live set. Eventually the runtime cannot get memory and the process dies, or the container's memory limit kills it. Restarting empties the backlog and the service looks healthy again for a while, which is why this often gets misfiled as a leak. The tell that distinguishes it from a leak of finished objects: what is retained is *unprocessed work*, and its size tracks how long the service has been behind, not how many requests it has served. ## The bounded version, and what the bound buys ```go events := make(chan Event, 1024) ``` Now the 1025th pending event parks the reader. Three things follow. First, a **memory ceiling you can compute**: at most 1024 queued events plus whatever the workers hold, so you can size the container instead of hoping. Second, **propagation**. Because the read goroutine is parked, it stops calling `Read` on the source. On a network source that means the socket's receive buffer fills and the TCP window closes, so the stall reaches the actual upstream producer rather than stopping at your process boundary. Backpressure that stops at your own heap is not backpressure. Third, a **decision point**. A blocking send is a place where the code can instead choose to wait a bounded time, honour cancellation, or record that an event was shed. An `append` offers nowhere to make that choice, which is why the failure is silent. ## Common wrong answers "Just make the buffer big enough." A large buffer is a *delay*, not a bound removal in the other direction: it absorbs bursts, which is useful, but under a sustained rate gap it only changes how long you have before the same wall. The property that matters is that it is finite. "The garbage collector will keep it under control." The collector frees unreachable memory. A backlog is reachable by definition — someone is going to process it. "`append` blocks when the slice is full." Slices have no full state; capacity is a growth hint, not a limit. ## The cost of bounding, stated honestly A bound converts a memory problem into a latency problem: something upstream now waits. That is a deliberate trade, and someone has to decide whether waiting or dropping is the right answer for that data. But you can only make that decision if the queue is bounded in the first place — an unbounded staging slice decides for you, and always decides "grow". A secondary smell: an in-memory staging slice shared between a reader and workers needs a mutex around it, so the unbounded design usually also adds lock contention on the hot path that a channel would not have.

  • How would you tell this apart from an ordinary memory leak?
    Look at what is retained. A leak retains objects whose work is finished — completed requests, cache entries nobody deletes. A backlog retains unprocessed work, and its size tracks how far behind the consumers are rather than how much traffic has been served. A heap profile showing a growing slice or queue of pending items, with no goroutine blocked on a send, is the backlog signature.
  • If the reader parks on a full channel, how does that reach the upstream producer?
    The parked goroutine stops calling `Read` on the source. For a network source the kernel receive buffer fills and TCP's flow control closes the window, so the remote sender is throttled. For a file or a pipe the same holds at the OS level. That is the whole point: the stall has to leave your process, or you have only moved the queue.
  • Does making the channel buffer very large give you the same safety?
    It gives you burst absorption, not a different outcome under a sustained rate gap — you just reach the ceiling later. What matters is that the buffer is finite and its size is chosen deliberately, so the worst-case memory is `cap` times item size and there is a defined moment where the producer must wait or the service must shed.

A bounded channel is a checkout lane with a fixed-length belt: when it fills, the person loading it has to stop. An appending slice is a belt that grows a new metre every time it fills, so the loader never stops and the shop runs out of floor.

saying these in an interview costs you the question

  • Claims append blocks or fails when the slice is full
  • Says the garbage collector keeps an unprocessed backlog bounded
  • Treats a very large buffer as equivalent to backpressure
  • Diagnoses the resulting OOM as a leak rather than a backlog
  • Thinks reading faster from the source helps a slow consumer