Crystal Fibers and Channels: Concurrency Without the Mutex Anxiety
Crystal's fibers and channels give you typed, backpressure-aware concurrency that reads like sequential code. Here's how the model works, a worker-pool example, and the pitfalls to avoid.
10 Apr 2026, 04:00 UTC

You need to fetch ten thousand URLs, process the results, and not melt your server doing it. In most languages this means choosing your poison: OS threads with locks everywhere, or a callback/async model that turns your code inside out. Crystal offers a third path that reads like plain sequential code but runs concurrently: fibers communicating over channels.
The thesis of this post is simple: Crystal's concurrency model is worth understanding because it gives you typed, backpressure-aware message passing with almost no boilerplate — but it has sharp edges around blocking I/O and unbounded channels that you need to know about before you ship it.
What a fiber actually is
A fiber is a lightweight green thread: a unit of execution with its own small stack, scheduled by Crystal's runtime rather than the operating system. Spawning one costs a fraction of what an OS thread costs, so creating thousands of them is unremarkable. You spawn one with a single keyword:
spawn do
puts "running in a fiber"
endThe runtime multiplexes fibers onto OS threads. Scheduling is cooperative in spirit: a fiber yields when it does something that would block (like a channel receive or async-aware I/O), letting another fiber run. This is why Crystal code can look synchronous while behaving concurrently — the yield points are hidden inside the standard library.
Channels are typed pipes with built-in backpressure
Fibers that share mutable state bring back all the locking problems you were trying to avoid. Crystal's answer is the same as Go's: don't share memory, pass messages. A Channel(T) is a typed conduit — the compiler enforces that you only send and receive values of type T, so a whole class of concurrency bugs becomes a compile error.
The blocking semantics give you backpressure for free:
channel.receiveblocks the calling fiber (not the OS thread) when the channel is empty.channel.send(value)blocks when the channel's buffer is full.
No mutexes, no condition variables, no select loops for the common case. Producers and consumers naturally pace each other.
A worked example: a small worker pool
Here's a producer feeding jobs to three worker fibers and collecting results. Save it as example.cr and run it with crystal run example.cr (any recent Crystal 1.x toolchain; no special permissions needed):
jobs = Channel(Int32).new(8) # buffered: 8 slots
results = Channel(String).new(16)
# Workers
3.times do |id|
spawn do
while job = jobs.receive?
results.send "worker #{id} processed #{job}"
end
end
end
# Producer
spawn do
10.times { |i| jobs.send i }
jobs.close # signals workers that no more jobs are coming
end
10.times do
puts results.receive
endA few things worth noting. receive? returns nil instead of raising when the channel is closed and drained, which makes the worker loop clean. close is how you tell consumers to finish up — forgetting it is the most common cause of a program that hangs at exit. The buffer sizes (8 and 16) are deliberate: they smooth out bursts without letting the producer run away.
To confirm the runtime did what you expect, compile with statistics enabled — CRYSTAL_OPTS="--stats" crystal run example.cr — and check that the program exits promptly with all ten results printed. If it hangs, the first suspect is a missing close or a mismatched send/receive count.
The trade-offs you should not skip
Unbounded channels are the default. Channel(T).new with no capacity grows without limit. If your producer outpaces your consumer in a long-running service, memory climbs quietly until it doesn't. Always pick an explicit buffer size unless you genuinely want an unbounded queue — and you almost never do.
Blocking calls inside a fiber stall progress. Crystal's scheduler can only switch fibers at yield points it knows about. A long CPU-bound computation or a call into a C library that blocks the OS thread will hold everything scheduled on that thread hostage. For CPU-heavy work, offload to a separate process or thread pool; for I/O, stick to Crystal's async-aware standard library sockets and HTTP clients rather than wrapping blocking native calls.
Fibers are not free. Each context switch has a small cost. For trivially short tasks, spawning a fiber per item can be slower than just looping. Fibers shine in I/O-bound, message-passing workloads — web servers, crawlers, pipelines — not in tight numeric loops.
Where to go from here
Start with the worker-pool pattern above: it's the shape most real Crystal concurrency takes. Give every channel an explicit capacity, always have a close story, and keep blocking native calls out of fibers. If you're evaluating Crystal for a service, write a small prototype that hammers a channel pipeline with realistic load and watch memory and latency — that ten-minute experiment will tell you more than any benchmark table.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.