Structuring I/O-Heavy Workers in Crystal with Typed Channels and Fibers
Crystal's typed channels and fibers make I/O-heavy worker pools read like plain blocking code — but fibers aren't threads. A worked example, backpressure notes, and the parallelism caveat.
15 Oct 2025, 17:52 UTC

If you've ever written a worker pool in Ruby, you know the drill: threads, mutexes, a Queue, and a quiet hope that nothing blocks the GVL at the wrong moment. Crystal offers the same readable syntax but compiles to a native binary, and its concurrency primitives — fibers and channels — let you write blocking-style I/O code that stays readable. The catch: fibers are not threads, and understanding that distinction is the real engineering decision.
The problem: coordinating I/O without callback soup
Say you're building a small service that fetches metadata for a list of URLs and stores the results. The work is almost entirely waiting on the network. You want a fixed number of workers, a way to feed them jobs, and a clean shutdown when the job list is exhausted.
In many languages this pushes you toward either explicit thread management or async/await annotations that infect every function signature. Crystal takes a third path: spawn starts a fiber — a lightweight, cooperatively scheduled unit of execution — and a Channel(T) moves typed messages between fibers. When a fiber does I/O (or receives from an empty channel), it yields so other fibers can run. Your code reads top-to-bottom like ordinary blocking code.
Typed channels replace ad-hoc queues
Because channels are generic, the payload type is checked at compile time. Define explicit message types instead of passing around hashes or tuples and hoping the keys line up:
record Job, id : Int32, url : String
record Result, id : Int32, status : Int32?, error : String?
jobs = Channel(Job).new
results = Channel(Result).new
WORKERS = 8
WORKERS.times do
spawn do
while job = jobs.receive?
begin
response = HTTP::Client.get(job.url)
results.send(Result.new(job.id, response.status_code, nil))
rescue ex
results.send(Result.new(job.id, nil, ex.message))
end
end
end
end
spawn do
urls.each_with_index { |url, i| jobs.send(Job.new(i, url)) }
jobs.close # signals workers to exit their loops
end
urls.size.times do
result = results.receive
# persist or log result
endTwo details matter here. First, receive? returns nil when the channel is closed and drained, which is the idiomatic way to end a worker loop — plain receive on a closed, empty channel raises. Second, close is what makes shutdown clean: each worker finishes its current job, sees nil, and exits. If a worker tried to send to a closed channel, that would raise, so only close a channel from the producing side once production is done.
If Result.new(job.id, response.status_code, nil) ever drifted — say you swapped the field order — the compiler would flag it before you ran anything. That's the payoff over a duck-typed queue.
Backpressure comes for free (if you choose it)
An unbuffered channel blocks the sender until a receiver is ready, which is natural backpressure: the producer can't outrun the workers. If you want a small cushion, use a bounded channel — Channel(Job).new(64) — and senders block only when the buffer fills. This is a deliberate design knob. A quick way to see it in action: add a puts before each jobs.send and watch it stall once all workers are busy. If it never stalls, your buffer is bigger than your workload, or your workers finish faster than the producer feeds them.
The trade-off: fibers are not multicore parallelism
This is the limitation to internalize before committing. Fibers shine for I/O-bound coordination because waiting fibers yield cheaply. But CPU-heavy work — image processing, compression, number crunching — does not speed up just because you spawned more fibers. Crystal's multithreading support and its build flags have evolved across releases, so check the current release notes and the -Dpreview_mt-style flags for your installed version before promising anyone parallel speedup. Run crystal --version and record it in your project README so behavior claims stay comparable over time.
The other constraint is ecosystem breadth. HTTP clients, JSON, and common drivers are well covered; niche SDKs often are not. Treat library availability as a design input, not an afterthought — check for shards covering your external services before you pick Crystal for the project.
Verifying the pool actually shuts down
Compile and run the example locally with crystal build worker_pool.cr (run in your project directory; no special permissions needed). The observable success check: the process exits on its own after the last result is consumed. If it hangs, the usual suspects are a worker still blocked on receive because the channel was never closed, or a result count mismatch — you must consume exactly as many results as jobs sent. A deliberate mismatch is a good local test: send five jobs but receive four, and confirm the program hangs, which tells you your accounting is what keeps shutdown correct.
When to make this choice
Choose Crystal for this pattern when you want a single native binary that's easy to deploy, your workload is dominated by network or disk I/O, and compile-time-checked messages appeal more than a vast library ecosystem. Reach for something else when you need proven multicore CPU parallelism today or depend on libraries Crystal doesn't have. Start with the worker-pool skeleton above, pin your compiler version, and test the close-and-drain path under realistic load before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.