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 run ldd. 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 "yaml", no regular expressions, and no import outside the allowed six. 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 that was the same as absent Issue #2125, "HTTP/2 support", was opened on 8 February 2016 and is still open. Ten years is long enough that most people read it as "Crystal has no HTTP/2", and that reading is now wrong — which is worth knowing before you rule the language out. The gap is filled outside the standard library, by ysbaddaden/http2 from Julien Portalier, a Crystal core contributor. Its status list has HPACK, frames and streams, flow control per stream and per connection, HTTP/1-to-HTTP/2 upgrades, server connections, integration into HTTP::Server, and a green run against h2spec 2.6.0. Adding it to an existing server is one require: require "http2/server" The remaining unchecked box is HTTP::Client; the author described in the issue what that would take. gRPC is in the same position — there is a pure-Crystal implementation, and it is somebody's shard rather than a stdlib module. So the honest form of this trap is narrower than "no HTTP/2", and it is the form that actually bit me: a shard is not the standard library. This project's whole premise is an empty dependency list, so a solution distributed as a shard is a solution it cannot take, however good it is. For anyone without that rule the cost is one dependency. For anyone with it, protocol support that lives outside stdlib is support that does not exist. Worth knowing either way: browsers require TLS for HTTP/2 even on localhost, so the shard also means certificates and bind_tls, not just a require. For this project it cost nothing: a ground station serves a handful of clients on a LAN, and HTTP/1.1 with a keep-alive is more than enough. Settle it up front anyway. Whether the protocols you need live in the standard library or in somebody's shard is a question for the week you pick the language, not for the month you discover the answer. Trap 5: the ecosystem lags the compiler Crystal releases move faster than the tools around them, and you will feel it at the edges rather than in the language. Ameba 1.6 does not build against Crystal 1.21 — the compiler's lexer API changed underneath it. The fix is to pin the development version by tag, which is what shard.yml does: development_dependencies: ameba: github: crystal-ameba/ameba version: 1.7.0-dev Pinned to a tag rather than a branch, so a checkout stays reproducible. Note the shape of the problem: the one linter everybody uses needed a pre-release to work with the current compiler. Cross-building on CI is where the days go. The arm64 + musl jobs failed before they started: actions/checkout is a JavaScript action, GitHub builds Node for Alpine only on x64, and the job died on an arm64 Alpine runner before reaching a single build step. The toolchain had to move inside docker run. Four defects in the release pipeline in total, none of which could appear locally, and all of which appeared on the first push. libm differs between platforms, and your tests must know it. The full SGP4 verification set passes on glibc and on musl, but not bit-for-bit: the worst position disagreement is 8.26 × 10⁻⁸ km on one and 8.29 × 10⁻⁸ km on the other, both 0.083 mm, because the two libms differ in the last place of their trigonometric functions. For scale, the two published reference implementations disagree with each other by 7 × 10⁻⁸ km, so this is the noise floor and nothing else. State numerical tolerances physically; a bitwise comparison would fail a correct implementation built against the other libc. Would I use it again For this shape of project, without hesitating. A daemon that has to be one file, start instantly, hold single-digit megabytes, run on a single-board computer with no network and no sysadmin, and still be readable a year later — Crystal is close to ideal, and I do not know a language that would have been meaningfully better. The zero-dependency rule was sustainable only because the standard library is good enough to make it sustainable. Against that: a standard library without HTTP/2 or gRPC, so anything modern on the wire means taking a dependency; a linter that needs a pre-release to match the compiler; cross-compilation that has to be learned the hard way; and a hiring pool of approximately nobody. If any of those are load-bearing for you, the decision is made. The project: github.com/VanyaNeytrino/kozai. Every number above is measured and reproducible from that repository — the memory figures in the README's "No dependencies" section, the allocation guarantee in spec/allocation_spec.cr, the purity rules in .github/workflows/ci.yml.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to