Crystal Fibers in Practice: Building a High-Concurrency Echo Server Without Callback Hell
Crystal's fibers let you write sequential-looking network code that scales to thousands of concurrent connections. Here's how the scheduler works, with a working echo server and channel-based broadcast example.
03 Jul 2026, 12:22 UTC

The problem: concurrency without the ceremony
If you need a network service that holds thousands of open connections, the usual options all have a cost. OS threads are heavy — a few thousand of them and context-switching dominates. Event loops with callbacks scale well but fracture your code into handlers. Async/await helps, but you still color every function.
Crystal takes a different route: fibers. A fiber is a lightweight, VM-scheduled unit of execution — essentially a green thread with its own small stack. You write ordinary, sequential-looking code, and the runtime suspends and resumes fibers for you whenever they hit a blocking operation. The useful takeaway: you get event-loop-level concurrency with thread-style readability, and the standard library's IO is already wired into the scheduler.
How the scheduler actually works
Crystal's runtime runs a scheduler per worker thread (historically single-threaded; multi-threaded mode exists behind a compile flag and has matured over time — check your version's docs before relying on it). When a fiber performs IO — a socket read, a channel receive, a sleep — the scheduler parks it and runs another ready fiber. When the IO completes, the fiber becomes runnable again.
Two primitives matter day to day:
spawn— starts a new fiber. Cheap; stacks start small and grow as needed.Channel(T)— a typed queue for passing values between fibers. Sends and receives block the fiber, not the thread, which makes it the natural way to coordinate work.
Because suspension happens at IO boundaries, you rarely call Fiber.yield yourself. The exception is CPU-bound work: a tight loop that never touches IO will starve other fibers on the same scheduler. For that case, insert an occasional Fiber.yield or offload to a separate process.
Worked example: an echo server
This compiles with any reasonably recent Crystal (1.x line; tested-by-you mileage may vary on older 0.x releases where IO APIs shifted). Save as echo.cr:
require "socket"
server = TCPServer.new("0.0.0.0", 7000)
puts "Listening on :7000"
while client = server.accept?
spawn handle_client(client)
end
def handle_client(socket : TCPSocket)
while line = socket.gets
socket.puts line
end
rescue IO::Error
# client disconnected mid-read
ensure
socket.close
endRun it from a shell with Crystal installed:
crystal run echo.crNo special permissions are needed for port 7000 (ports below 1024 would require elevated privileges). Each accepted connection gets its own fiber via spawn handle_client(client). The gets call looks blocking, and it is — but only for that fiber. While one connection waits for data, thousands of others can be serviced.
Verify it with two terminals: nc localhost 7000 in each, type lines, and confirm both get echoes back independently. For a rough concurrency check, a tool like tcpkali or a small script opening many sockets will show connections staying open simultaneously without the process spawning a thread per client — ps -o nlwp -p <pid> should show a small, stable thread count.
Coordinating fibers with channels
Say you want to broadcast every received line to all connected clients. Channels make this straightforward without locks:
record Client, socket : TCPSocket, inbox : Channel(String)
clients = Channel(Client).new
broadcast = Channel(String).new
spawn do
all = [] of Client
loop do
select
when c = clients.receive
all << c
when msg = broadcast.receive
all.each { |c| spawn c.inbox.send(msg) }
end
end
endThe select expression waits on multiple channel operations at once — Crystal's answer to select in Go. Here a single fiber owns the client list, so there's no shared mutable state and no mutex. Each client fiber sends outgoing messages from its own inbox channel, which also gives you natural backpressure: a slow client fills its inbox rather than blocking everyone.
Trade-offs and limitations
- CPU-bound work doesn't parallelize by default. The classic single-scheduler model means one busy fiber can stall everything. Use
-Dpreview_mt(or your version's documented multi-threading flag) cautiously, and verify with a load test — multi-scheduler behavior has changed across releases. - Blocking C calls block the thread. A C library call that sleeps internally won't yield the fiber. Wrap such calls or isolate them.
- Memory still scales with connections. Fibers are cheap, not free — each holds a stack and any buffered IO. Claims of a million connections on tiny VMs circulate in blog posts; treat them as directional, and measure with your own payload sizes before capacity planning.
- Debugging is different. A runaway fiber won't show up as a busy thread in
top -H. Add logging aroundspawnsites and consider a fiber-count gauge viaFiberintrospection or your own counter.
Where to start
Take an existing socket or HTTP endpoint in your service and wrap its per-connection handler in spawn. Then load-test: open a few hundred concurrent connections and watch thread count (ps -o nlwp) and RSS (/proc/<pid>/status). If threads stay flat while connections climb, the fiber model is doing its job. From there, introduce channels only where fibers genuinely need to coordinate — the biggest mistake is reaching for shared state and mutexes out of habit when a channel would keep the design single-owner and race-free.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.