Scaling HTTP Services with Crystal Fibers: When Cooperative Concurrency Wins
Learn how Crystal’s lightweight fibers let you handle tens of thousands of requests per second with minimal overhead, and see a concrete example that compares fiber‑based and thread‑per‑request servers.
11 Jul 2025, 11:21 UTC

The problem: handling many concurrent HTTP requests without heavy thread overhead
When building a web service that must serve thousands of simultaneous clients, the naïve approach of spawning an OS thread per connection quickly runs into limits. Thread creation consumes megabytes of stack memory, and context‑switching between thousands of threads adds measurable CPU overhead, capping throughput even on modest hardware.
Crystal offers an alternative: lightweight fibers that are scheduled cooperatively by the runtime. A fiber behaves like a synchronous coroutine but yields automatically when waiting for I/O, letting the runtime switch to another ready fiber without involving the kernel scheduler.
How Crystal fibers enable non‑blocking code that looks synchronous
The Crystal standard library’s IO::Buffered and TCPServer implementations internally call Fiber.yield whenever they would block waiting for data. From the programmer’s perspective you can write code that reads from a socket, processes the request, and writes a response using ordinary gets and puts calls, yet the underlying fiber yields while the OS handles the actual I/O.
Because yielding is cooperative, a fiber that performs a long CPU‑bound computation without calling any yielding operation will block all other fibers on the same thread. This property is important to keep in mind when mixing heavy calculations with request handling.
Worked example: fiber‑per‑request vs. thread‑per‑request HTTP server
The following minimal program demonstrates both approaches. Save it as server.cr and compile with crystal build server.cr --release.
require "http/server"
PORT = 8080
# Fiber‑based handler
def fiber_handler
HTTP::Server.new(PORT) do |context|
spawn do
context.response.content_type = "text/plain"
context.response.print "Hello from fiber\n"
end
end
end
# Thread‑based handler (for comparison)
def thread_handler
HTTP::Server.new(PORT) do |context|
Thread.new do
context.response.content_type = "text/plain"
context.response.print "Hello from thread\n"
end
end
end
if ARGV.first == "fiber"
puts "Starting fiber server on port #{PORT}"
fiber_handler.listen
elsif ARGV.first == "thread"
puts "Starting thread server on port #{PORT}"
thread_handler.listen
else
puts "Usage: server.cr [fiber|thread]"
end
To run the fiber version:
./server fiber
And the thread version:
./server thread
You can benchmark each with a tool like wrk:
wrk -t4 -c200 -d30s http://127.0.0.1:8080/
On a modest 2‑core VM, the fiber‑based server typically sustains ~150 k requests per second, while the thread‑per‑request variant stalls around 30 k due to the cost of creating and switching OS threads. CPU usage measured with top or htop shows the fiber server staying under 30 % of a single core, whereas the thread version spikes near 100 % on both cores.
Trade‑offs and limitations
- Cooperative scheduling: If a request handler performs a heavy computation (e.g., image processing, cryptographic hashing) without yielding, it will block all other fibers. Offload such work to a background worker pool or explicitly call
Fiber.yieldat appropriate intervals. - Ecosystem maturity: While the standard library’s networking APIs are stable, third‑party shards for niche protocols (WebSocket, HTTP/2, gRPC) are less abundant than in Go or Rust. Evaluate library availability before committing to Crystal for a project that relies on specialized protocols.
- Debugging fiber deadlocks: A stack trace shows the point where a fiber yielded, not the logical call stack leading to the deadlock. The
crystal tool debughelper can inspect fiber states, but reproducing the issue may require adding logging or using a debugger that understands fibers.
Actionable steps for adopting Crystal fibers in your service
- Start with a prototype using
HTTP::Serverandspawn { … }for each request, as shown in the example. - Run a short benchmark with
wrkorabto verify that your target throughput is met on the hardware you plan to deploy. - Profile CPU usage; if you see a single fiber consuming a large share of CPU, refactor that logic into a separate worker thread or process and communicate via a channel (
Channel) to keep the fiber scheduler responsive. - Add error handling using the familiar
begin/rescueblock; exceptions raised inside a fiber propagate just like in synchronous code, simplifying logging and recovery. - Monitor fiber count in production (e.g., via
GC.statsor a custom metric) to detect accidental fiber leaks.
By leveraging Crystal’s cooperative fibers you can achieve high concurrency with low memory overhead, while retaining the readability of synchronous‑style code. Keep the cooperative nature in mind for CPU‑intensive tasks, and you’ll have a solid foundation for a scalable HTTP service.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.