Crystal in 2026: a 7 MB binary, zero dependencies, and five traps
I spent a few days writing a satellite ground station daemon in Crystal, with an empty dependency list and a hard rule against third-party code. It works, it ships as one file, and it sits at 1.9 MB of memory at rest. This is what the language was like to use, and what it cost. The project is kozai: it reads orbital elements, propagates them with SGP4/SDP4, predicts passes over a ground station, serves a JSON API and an offline web interface, and drives a rotator and a radio through hamlib. About 9,000 lines of source and 6,400 lines of specs, on Crystal 1.21.0. None of that matters here except as the load under which the language was tested - this is a report on the tool, not on the satellites. What the language actually delivers The headline claim of a compiled language with a garbage collector is that you get Ruby's ergonomics and a binary at the end. In 2026 that claim holds, and the numbers are the part worth quoting: Docker image, FROM scratch | 7.41 MB | | Static binary, musl, arm64 | 6.9 MB | | Dynamic binary, release | 1.9 MB | | Memory at rest, 2 satellites | 1.9 MB | | Memory at rest, 97 satellites | 4.3 MB | | Memory after a day of serving, 97 satellites | 19.3 MB, flat | Build steps before crystal build | none | | Runtime files outside the binary | none | The last two rows are the ones that changed how the project was built. There is no Node in this repository, no bundler, no asset pipeline, and no postinstall . The web interface - HTML, CSS, JavaScript, and a 66 KB SVG of the world's coastlines - is read at compile time by {{ read_file(...) }} and lives inside the executable (src/assets.cr ). Deploying is scp . The standard library covered the whole surface of a network daemon with six imports: http/server , http/client , json , log , socket , option_parser . That list is not an aspiration; CI fails if a seventh appears. The type system earned its keep in the numerical core. Predicting a week of passes for a hundred satellites is on the order of ten million propagator calls, and the hot loop allocates nothing: positions and satellite state are structs, and propagation failures are reported through an enum instead of an exception, because raise allocates. A spec propagates 200,000 steps on the near-earth branch and 50,000 through the deep-space integrator, and asserts that heap growth is zero (spec/allocation_spec.cr ). It passes. Getting that from a GC'd language, without writing anything that looks like C, is the reason to be here. So much for the brochure. Here are the five things that cost me time. Trap 1: the inline rescue does not filter by type This one is specific to Crystal, and it is the one I would warn a newcomer about first. Crystal has a suffix rescue , inherited in spirit from Ruby: value = risky_call rescue fallback In a block form, rescue IO::Error means "catch this class of error". In the suffix form it does not. The suffix form has no type filter at all: it catches everything, and the thing on the right is the value returned on failure. So this line, which closed a socket in a mock server without caring whether it was already closed: socket.close rescue IO::Error does not mean "catch IO errors". It means "catch every exception, including the ones that indicate a bug, and on failure evaluate to the class object IO::Error ". The code reads as if it were correct. It compiles, it type-checks, and it will happily swallow the failure you needed to see. The fix is the block form, which does filter: private def close_quietly(socket : TCPSocket) : Nil socket.close rescue IO::Error end I did not find this by reasoning about it. Ameba, the linter, found it. That is the useful lesson: the trap is invisible during review precisely because it looks like the block form, so run the linter and believe it. Two smaller sharp edges live next door. A macro cannot be expanded inside a rescue clause. The parser rejects it. I needed the rescue list to depend on a compile-time flag, because a build without OpenSSL has no OpenSSL::Error type, and naming a type that does not exist will not compile. The way through is an alias, declared once (src/catalog.cr ): {% if flag?(:without_openssl) %} alias TransportError = IO::Error | Socket::Error {% else %} alias TransportError = IO::Error | Socket::Error | OpenSSL::Error {% end %} and then rescue ex : Error | TransportError at the call site. Exceptions from the standard library are easy to under-catch. The same loader missed OpenSSL::SSL::Error , so a TLS failure killed the daemon instead of falling back to its cache - the exact opposite of the offline behaviour the project exists to guarantee. It surfaced only when the binary ran inside a FROM scratch image, where OpenSSL could not find a CA bundle. A dependency this project deliberately has none of would not have helped; reading the error hierarchy would have. Trap 2: the fiber stack pool looks exactly like a memory leak This is the one that nearly went into a release note as a defect in Crystal's standard library. It would have been wrong. The daemon is meant to run for weeks unattended, so I put it under continuous request load and sampled memory. The live heap, measured after a forced GC.collect , grew linearly: 0.21 MiB per minute, about 300 MB per day. That is a leak by any reasonable reading. I isolated it. Thirty lines, a bare HTTP::Server with one ErrorHandler and not a single line of my project, and the shape reproduced: roughly 75 KiB retained per request when each request arrived on a new TCP connection. At that point I had a clean reproduction against the standard library and a draft sentence about a leak in HTTP::Server . The sentence was wrong, and one more measurement showed why. Instead of extrapolating the line, I asked whether it saturates: first 250 requests: +13.4 MiB 250 โ 500: +2.4 MiB 500 โ 750: โ6.8 MiB โ memory comes back beyond: 11-20 MiB, no trend 2000 requests on a single connection: โ0.07 MiB It is not a leak. Crystal pools the stacks of finished fibers, and this server runs one fiber per connection. A server that has handled a burst of concurrent connections holds more live data than one that just started, up to the high-water mark of concurrency it has ever seen - and then it stops. Thirteen minutes of a perfectly straight line in a container was the pool filling up slowly, because I was sampling once a minute. Two things follow, and both generalise beyond Crystal. RSS tells you nothing here. Boehm does not return pages to the operating system unless it is built with USE_MUNMAP , so resident memory cannot fall and its flatness is not evidence of anything. Measure the live heap after a forced collection. "Zero growth" is the wrong acceptance criterion; "reaches a plateau" is the right one. Restated that way, the soak is a clean pass: over 13.8 hours the live heap climbed from 4.9 MB to 19.2 MB during the first four hours, then held between 19.19 and 19.37 MB for the remaining 9.8 hours and 576 samples. The residual trend is 14 KB/hour - 250 times below the fill rate, and the same size as the scatter between consecutive samples. RSS over the same period sat at 12.3-14.2 MB. If you are writing a long-running Crystal service, budget an afternoon for this and do not report the first curve you see. Trap 3: the standard library links C you did not ask for "Zero dependencies" means an empty dependencies: block in shard.yml . It does not mean the binary contains no C. The runtime stands on Boehm, libc and libm - that is the language, not your supply chain. What surprised me is how much C arrives through ordinary require lines. - require "yaml" links libyaml. For a config file of a few dozen keys that is a poor trade, so configuration is parsed by hand. - Regular expressions link PCRE2. TLE parsing is by fixed columns anyway - the format demands it - but the point is that one =~ in a cold path pulls a C library into a binary meant to be static. - HTTP::Server links OpenSSL for its TLS support whether or not you use TLS. This is the one you cannot deduce from the source you wrote. A-Dno_network build removes the HTTPS client and still links OpenSSL; you need-Dwithout_openssl as well, and the only way to discover that the first time is to build the thing and runldd . The project now prints a compile-time notice if you pass one flag without the other. The same pressure shows up in small places. Static assets are served with an ETag derived from their bytes, and the obvious way to compute one is a digest from the standard library - which links a C library, for a checksum whose collisions do not matter. The next obvious thing is String#hash , and that is a trap of its own: Crystal seeds it randomly per process, so every restart would invalidate every browser cache. The ETag is therefore a hand-rolled 64-bit FNV-1a, eight lines in src/assets.cr . Twice now, "use the standard library" has been the wrong answer for reasons that have nothing to do with quality. Because these are properties of the product rather than preferences, CI enforces them (the purity job in .github/workflows/ci.yml ): shard.yml must declare no runtime dependencies, src/ must contain no lib blocks, no require , no regular expressions, and no import outside the allowed six. "yaml" That job also taught me something about enforcement. Its first version grepped for .scan( and .match( , which flagged the project's own Passes.scan - a false positive that would have had someone rename working code to satisfy a grep. It now matches on the constructs (Regex , =~ , a slash immediately after the parenthesis) rather than on method names. A purity check that produces false positives does not get tightened; it gets ignored. And the honest footnote: libpcre2 is in the binary regardless, because OptionParser uses regular expressions internally. The codebase contains none. The dependency is the standard library's, not mine, and I cannot remove it without giving up argument parsing. Trap 4: HTTP/2 is not in the standard library, and for me t
Comments
No comments yet. Start the discussion.