I wrote kanal to prove that Rust channels can be faster than anything else in the ecosystem. Since the first release it has offered unbounded() next to bounded(n), like std, crossbeam, flume, and tokio. For about a year I have been meaning to write the post that says we all got this wrong, the post that deprecates unbounded channels in kanal.
This is not that post. The belief survived a year in my head and broke in a few places on the page. What is left standing is a smaller, more solid claim: an unbounded channel is a check written against memory that your program cannot promise to cash, unless something else in the system is the account, and in most programs nothing is.
What follows is the accounting: why unbounded channels are mostly a mistake, the few places they are right, and why kanal keeps them anyway.
Every channel is bounded
An unbounded channel is a bounded channel whose limit you chose not to write down. The queue lives in memory, memory has a size, and unbounded() still picks a bound: the machine's RAM, the container's cgroup limit, the patience of the OOM killer. It also picks a failure mode: SIGKILL for the whole program.
Every send commits a little more memory on the program's behalf, and the API promises that no one has to watch the total. The kernel is the final enforcer. The day the limit is crossed, it does not fail one send; it ends the whole program.
A queue design answers two questions: how many items may wait, and what happens to the producer when the line is full. bounded(n) answers both at the design table. unbounded() answers "all the memory we have" and "the kernel ends the program", the worst available answer to each.
That failure arrives hours late, takes down every task in the process, and points at nothing, since the allocation that crosses the limit can live in any subsystem. A blocked sender, by contrast, is a thread parked inside send() at a line number, holding a reference to the channel that parked it.
Queues do not fix overload
A queue absorbs the difference between the rate work arrives and the rate work completes, and the difference comes in two shapes. A burst is temporary: the queue fills, the consumer catches up, the queue drains. Overload is sustained: arrivals exceed service on average, and no queue of any size repairs that, because the backlog grows at the rate of the deficit for as long as it lasts.
The same buffer is a shock absorber for the first shape and a garbage pile for the second, and it cannot tell you which one it is being. A backlog is delayed work, and delay only helps if spare capacity is coming to absorb it. After a burst, the next quiet moment supplies that capacity. Under sustained overload the quiet moment never comes, and the unbounded channel lets the backlog grow anyway.
Little's law does the arithmetic: items waiting equals arrival rate times time spent waiting. Push 110,000 messages per second into a consumer that handles 100,000 and the queue gains 10,000 per second, about 2.3 GB per hour at 64 bytes each. The channel has converted a throughput problem into a memory problem and scheduled the crash for the afternoon.
Little's law Proved by John Little in 1961, one of the oldest results in queueing theory: in a stable system, the average number of items inside equals their average arrival rate times the average time each item spends inside:
L = λ × Witems in the system = arrival rate × time spent insideIt holds with almost no assumptions: any burst pattern, any scheduling order, any kind of item. A checkout line that receives two customers per minute and keeps each for three minutes holds six people on average: L = 2 × 3. Read forward, it turns a rate and a delay into a queue length; read backward, it turns a queue length into the latency you signed up for.
Growth is the loud failure; latency is the quiet one. A message entering a five-million-deep queue served at 100,000 per second waits 50 seconds, so long before the process dies it is acting on a world a minute old. Networking ran this experiment at planetary scale and named the result bufferbloat: routers with giant buffers kept throughput high while latency stretched into seconds, and the fix, CoDel, drops packets earlier and on purpose. Fred Hebert wrote the server-side lesson in Queues Don't Fix Overload: the queue does not remove the overload, it hides it while the deficit grows.
And overload feeds itself. Somewhere upstream a timeout fires, a client retries, and the retry arrives as one more send the queue accepts; load climbs exactly when the system can least afford it, and the backlog fills with requests and their own duplicates, none answered soon enough to stop the next round. A bound breaks the loop at the door, failing retries fast while the queue is full. Unbounded, the loop closes: the slower the system gets, the more work it accepts.
An API that cannot say no
bounded(n) forces a conversation: when this queue is full, does the producer wait, fail, or throw work away? Kanal puts the options in the API: send() waits and pushes backpressure upstream, try_send() fails so you can shed load at the edge, send_timeout() waits with a deadline. Each is a legitimate overload policy, and choosing one costs ten minutes in a design review.
unbounded() removes the question, which I think explains most of its popularity: it is the lazy answer. It does not solve the slow reader; it denies that the reader exists. Send never blocks, the demo works, the tests pass, the review finds nothing to discuss.
Code that seems to work while a problem grows under it is more dangerous than code that fails, because failure files a report and growth stays silent. The reader is still back there, falling behind. The queue length measures how far, and unbounded() is the decision to stop reading the gauge.
The program still has an overload policy; you never wrote it, and it defaults to: grow until the kernel picks a victim. A full bounded channel, meanwhile, is information, naming the consumer and the edge at the moment it fell behind. A climbing RSS graph points at no channel and no line of code.
Underneath the policy question sits a naming complaint: an unbounded channel is barely a channel at all. A channel, in the CSP tradition kanal is named for, is a synchronization primitive. Both ends can block, and the blocking is the communication: the receiver waits for data, the sender waits for room, and each side learns about the other by being made to wait.
An unbounded send returns Ok before the other side has moved. The Ok means the queue accepted the message, not that anyone will read it, and everything upstream treats it as delivered. Delete the sender's half and the conversation becomes a monologue into a buffer: a queue with a doorbell on the far end.
Queues are honest tools when you pick one on purpose, growth policy in hand. unbounded() hands you a queue wearing a channel's name, with the defining feature removed.
The honest objections
A claim this broad against a feature std itself ships deserves the strongest counterarguments. Unbounded channels have three good ones, and a fourth heard more often than any of them.
The strongest: bounded channels deadlock in cycles. Task A sends to B, B replies to A, both queues fill, both tasks park inside send(), and the pipeline freezes with no error anywhere. An unbounded edge would have kept things moving.
I accept the objection in full and still prefer the bounded behavior, because of where each failure tends to surface. The deadlock appears in the first serious load test and hands you the cycle in a stack dump. The unbounded version passes the test, because the test ends before memory does, then fails weeks later as an OOM kill in which no cycle is visible.
Tends is the honest verb, though. A bound that must be guessed can be guessed too small, planting a stall in production on a path the tests never loaded, a failure the unbounded edge did not have. A topology that needs unlimited in-flight data is broken in both versions, and the bounded channel is the version that files the report. A topology whose in-flight data is bounded somewhere else is a different case, and it gets its own section below.
The second: contexts that cannot block. Drop implementations, FFI callbacks, GUI handlers, panic hooks. The answer is try_send(): on a full queue, drop and count, or merge into a latest-value slot, or spill into a local buffer. If the message can neither wait nor be lost, RAM was the wrong home for it; a durable log on disk has a bound measured in terabytes and survives the restart that erases a queue.
The third: "my load is bursty, the average is fine." Then the queue you need is the size of the burst, and bounded(burst) gives identical behavior with a guarantee attached. If you cannot put a number on your largest burst, you do not know your system's worst case, and unbounded() postpones that discovery until production.
The fourth: "we will export the queue length and alert on it." Monitoring discovers the problem; it does not answer it. An alert converts a structural guarantee into manual work: the blocking send re-implemented as a message to an on-call engineer, with minutes of human reaction in place of microseconds. And the threshold makes my point: if you can name the queue length at which someone must act, you have already chosen the bound. You are enforcing it with people instead of code.
Some users will shrug and write bounded(10_000_000). I count that as a real win: a generous bound is still a statement, a circuit breaker rated high rather than no breaker at all. One kanal-specific footnote: bounded(n) today allocates all n slots at construction, 640 MB for ten million 64-byte messages. That price matters at the end of this post.
Where unbounded is the right call
The title says mostly, and the way to earn that word is to name the exceptions. I know of three, and they share one property: somewhere else in the system, a real bound exists and is enforced. The channel is unbounded; the system is not.
Waker edges inside runtimes. An executor that parks a task must be told to wake it: at most one wakeup per parked task, a task count already limited upstream, and a sender that must never block, because it is often the thread the receiver is waiting on. Tokio's scheduler has this shape inside it: fixed-size local run queues overflow into an unbounded injection list whose true bound is the number of spawned tasks. A numeric capacity here would duplicate a number the runtime already controls, and a wrong duplicate could deadlock the executor itself.
Interior edges of a pipeline bounded at the front door. Limit entry with a bounded channel or a semaphore of n permits, and every interior queue inherits the bound, because only n items exist inside the system at once. Unbounded interior feedback edges then remove the cycle deadlock from the previous section without giving up the memory guarantee, which never lived in those channels; it lives at the door. Bound the entry, free the interior.
Short-lived programs with finite input. A tool that lists ten thousand files and fans them out to workers holds a queue bounded by input it already read. The bound is the size of the job, the process ends when the job does, and the operator watching the terminal is the backpressure. A capacity here is a setting with no information behind it.
The test that separates these from the mistakes fits in one sentence. If you can finish "this queue cannot exceed N items, because...", then unbounded() is paperwork, and the comment above it should contain that sentence. If you cannot, your queue is not unbounded, it is unexamined, and the check has your signature on it.
The implementation cannot save you
Everything above applies to any channel library. This section comes from source, kanal's and crossbeam's, because the two make opposite bets on the same problem and it is worth watching both lose.
Kanal bets on the steady state. Its unbounded() is a bounded channel in disguise: a sentinel capacity of usize::MAX over a VecDeque that starts at 32 slots and doubles when full. The doubling happens while the channel's internal lock is held, so a backlog of two million 64-byte messages doubles by moving 128 MB while every thread on the channel waits, and the buffer never shrinks afterward.
The documentation calls this a warmup phase, and that is the design: grow rarely and in large steps, then run at full speed out of one contiguous, cache-friendly buffer with the allocator out of the loop. The cost is the pause at each doubling and a memory floor set by the channel's worst day.
Crossbeam bets on smoothness. Its unbounded channel is a linked list of chunks, 31 messages to a block: no big buffer, no doubling pause, and drained blocks go back to the allocator, so memory follows the backlog down. The cost is the allocator on the hot path: every 31 messages some sender allocates a fresh block as part of its send, forever, and a thread that reaches the boundary first spins until the block is installed.
Kanal pays rarely and keeps the memory; crossbeam pays constantly and gives it back. Neither choice is wrong. They are two payment plans for the same debt.
And the debt was never the allocator's. Every message a slow reader has not consumed is live memory that no allocation strategy can reclaim, because the program still promises to use it. A reader that stays slow turns either design into a memory leak no tool will flag: every byte reachable, accounted for, and useless. The implementation decides how the process walks toward the OOM. It does not change the destination.
This is why removal tempted me. Without unbounded(), kanal's queue memory becomes a constant the caller chose, the growth path and the sentinel disappear, the test matrix shrinks by a dimension, and a fixed power-of-two ring could replace VecDeque.
I write Rust for high-integrity systems under rules that ban heap allocation after initialization, the rule NASA's Power of Ten imposes on flight software for the same reason: allocation that scales with load is the failure you cannot test for. All of that is true, and it is still not the whole story.
Picking a capacity
Replacing an unbounded() means choosing a number, and my starting answer is smaller than most people expect: zero or one. I believe most channels should be bounded(0) or bounded(1).
Zero is a handoff: the sender waits until a receiver takes the value from its hands, the two sides move in step, and nothing sits in a buffer pretending to be progress. One decouples the sides by a single step: a producer preparing the next item while the consumer works, or a mailbox slot holding the latest value. Anything past one is extra room, and extra room needs a number you can defend.
I designed kanal around these two sizes. When a receiver is parked and waiting, kanal moves the value straight from the sender's stack to the receiver's stack, no queue in the middle, and at capacity zero and one that direct path is the common case. The buffer is the detour; the handoff is what kanal is built to make fast.
The honest trade-off: a buffer lets the two sides wake each other less often, so a buffered channel usually beats a pure handoff on raw MPSC throughput. By Little's law, 1,024 slots feeding a consumer at 100,000 messages per second holds at most ten milliseconds of lag; if the profiler says the wakeups hurt and the latency budget covers it, buy the buffer. Zero and one are where the reasoning starts, and a benchmark is a number you can defend.
When the workload justifies more, two formulas produce the number. For latency, run Little's law in reverse: capacity equals send rate times the delay you will accept, so a pipeline moving 50,000 messages per second with 100 tolerable milliseconds wants bounded(5_000), and anything deeper is latency you create for yourself. For bursts, hold the spike: 10,000 events over two seconds against a consumer draining 2,000 per second peaks near 6,000 queued.
Then pick the policy for the full case, the decision unbounded() was hiding:
// Backpressure: slow the producer to the consumer's pace.
sender.send(job)?;
// Shedding: never wait, count what you drop.
if sender.try_send(event).is_err() {
DROPPED.fetch_add(1, Ordering::Relaxed);
}
// Deadline: wait, but not past the SLA.
sender.send_timeout(task, Duration::from_millis(50))?;
And when the requirement reads "no message may be lost even if the consumer is down for an hour", no RAM queue of any size was the right tool; that requirement names a durable log on disk.
What the neighbors decided
Go shipped bounded channels in 2009 and has not moved since: make(chan T, n), with n defaulting to zero, and no unbounded variant in the language sixteen years later. Capacity is treated as part of the program's meaning.
Rust's standard library put the footgun in the short name. The documentation describes channel() as "an asynchronous, infinitely buffered channel", while the bounded version carries the longer, stranger name sync_channel(n). Defaults teach, and this one teaches that the capacity question is optional.
Tokio kept unbounded_channel, its documentation warning that a slow receiver grows the buffer until the process runs out of memory, an API apologizing for itself. Crossbeam, flume, and kanal ship unbounded() too.
Erlang is the longest-running experiment: mailboxes unbounded by design, the mailbox that ate the VM a famous failure story, and OTP's eventual answer, max_heap_size, a flag that kills a process whose heap outgrows a limit. The community with the most unbounded-queue experience added a bound after the fact and enforced it with a kill switch; I would rather ship it up front and enforce it with a Result.
Why kanal keeps them, for now
I wanted this post to end with a removal notice; kanal 0.2 is in beta as I write, and a breaking release is the natural moment. Three things stopped me.
-
The exceptions are real. The waker edge, the entry-bounded pipeline, the finite batch job: correct programs whose authors can finish the because-sentence. A removal would punish the people who did the reasoning this post asks for.
-
Kanal is not yet ready to catch everyone the removal would push out.
bounded(n)allocates its whole buffer at construction, so the careful user who wantsbounded(10_000_000)as a circuit breaker pays hundreds of megabytes for slots meant to stay empty. Generous bounds should cost nothing until the traffic arrives; kanal cannot offer that today, and I will not push people offunbounded()before it can. The fix I have in mind comes at the end of this post. -
And removal converts nobody. Go ships bounded-only channels because Go never offered the alternative; taking an API away is a different act from never selling it. A user pushed off kanal's
unbounded()does not sit down with Little's law; they add a different crate with the same unbounded queue inside, and the ecosystem stays where it was. A library cannot remove a habit. It can publish the argument, and that is what this post is.
So unbounded() stays, with changes: its documentation will state the real cost, carry the because-sentence test, and link to this argument, so the warning sits where the decision gets made. I wanted kanal's name off this check. It stays co-signed for now.
There is a third option I keep circling, and I am putting it in front of you as an idea, not an announcement: bounded_lazy(initial, max), a constructor between bounded(n) and unbounded() that borrows the better half of each. It opens with initial slots, grows on demand the way unbounded() grows today, and stops at max, where it recovers the ability to say no.
From bounded(n) it keeps the contract: a real ceiling and a policy for the full queue. From unbounded() it takes the allocation model: paying for the backlog you have instead of the ceiling you fear. bounded_lazy(32, 10_000_000) costs kilobytes on day one and can never cost more than the breaker you rated.
It will not write the because-sentence for you; max is still yours to defend. But it removes the last honest excuse for skipping the number, that naming one was expensive. If it earns its place, most of what people reach for unbounded() to get would live there, behind a limit they chose.
If you disagree in either direction, the comments below are open: bring me the workload whose bound cannot exist anywhere, the argument for why my three exceptions are mistakes too, or your opinion on whether bounded_lazy deserves a slot in the API. The title of this post used to say something stronger.