Demystifying BEAM Binaries: How We Made Our DNS Library Lightning Fast (Without Any NIFs)
When you're running a platform that handles domain registration, DNS hosting, and cloud infrastructure like we do at NameOcean, performance isn't just a nice-to-have—it's the foundation of reliability. That's why we get excited when we see clever optimization stories from the Erlang ecosystem, and why we want to break down one of our favorites for you.
Recently, we came across a fascinating deep-dive from the DNSimple team about their work optimizing dns_erlang, their open-source DNS library. Their results were striking: 12% less CPU per query and 14% more queries served per second—all from pure BEAM optimizations, no NIFs required. For anyone building high-throughput systems in Elixir or Erlang, this is essential reading.
Let's unpack what makes this possible.
The BEAM Binary Mental Model
Here's something that surprises many Elixir developers: appending to binaries in a tight loop isn't as expensive as you might think.
When you write something like:
def build_response(acc, []) do
acc
end
def build_response(acc, [h | t]) do
build_response(<<acc::binary, h>>, t)
end
You might expect this to be O(n²)—each iteration creating a new binary and copying everything. But the BEAM is smarter than that.
Because binaries are immutable, the compiler can prove that if acc was just created by a previous append and nothing else holds a reference to it, writing directly into that existing memory is safe. The first append allocates some spare capacity, and subsequent appends write into it. The result? Amortized O(1) per append—identical to how a std::vector works in C++.
The catch? If your binary "escapes"—meaning it gets stored in a process dictionary, returned to a caller, or appended onto something else—the party is over. You're back to copying.
This pattern shows up everywhere in binary protocol handling. When you're building DNS responses iteratively, keeping that accumulator flow tight matters enormously.
Sub-binaries: Free Slicing (Mostly)
Matching on binaries creates what the BEAM calls sub-binaries—tiny terms that point into the original data without copying. When you write:
<<Header:12/binary, Rest/binary>> = Packet
No data gets copied. Rest is just a window into the original packet.
But here's the subtlety that trips people up: the BEAM keeps a match context internally when you recursively match, essentially a cursor walking through the bytes. It won't materialize a sub-binary unless you actually let it escape—returning it, storing it, passing it somewhere it might stick around.
This matters enormously for DNS parsing. Every query arrives as a binary, gets matched against the protocol structure, and needs to produce a response. Understanding when you're creating work versus just viewing data is the difference between a parser that scales and one that chokes under load.
Why Integer Representation Matters More Than You'd Think
On a 64-bit BEAM, integers take a few bits for their type tag. That leaves 60 bits for the actual value. Integers that fit in that space are "small integers"—immediate values that live directly in process registers, never touching the heap.
Anything larger becomes a bignum, which requires heap allocation and garbage collection consideration. This sounds esoteric, but it becomes critical when you're doing the kind of bit-twiddling that binary protocol work demands.
The technique is called SWAR (SIMD Within A Register)—packing multiple values into a single integer so you can operate on them in parallel at the CPU level. For DNS, this means parsing many bytes worth of data in each operation. But it only works if your packed values fit in the "small integer" sweet spot.
When they don't, you're not just paying memory allocation costs—you're potentially triggering garbage collection cycles that wouldn't exist otherwise. At millions of queries per second, those cycles add up.
What This Means for Your Code
Here's the practical takeaway: you don't need to write NIFs or reach for C to get serious performance wins. You need to understand how the BEAM thinks about data.
A few patterns worth internalizing:
1. Keep binary accumulators tight. Append-only loops are your friend. Once a binary escapes, the cost model changes dramatically.
2. Match in place when possible. Use pattern matching to view data rather than copying it. The BEAM is optimized for this.
3. Watch your integer sizes. When doing arithmetic or bit manipulation, think about whether your values will stay in the "small integer" range.
4. Profile with reductions in mind. The BEAM schedules work in reductions (roughly function calls). A hot loop that does more matching in fewer function calls does less work from the scheduler's perspective.
For anyone building systems that handle binary protocols—whether that's DNS, WebSocket frames, or custom protocol implementations—these patterns apply directly. The BEAM gives you incredible tools for writing fast code, but they're only useful if you understand the model.
We've seen teams reach for NIFs or Rust NIFs when a deeper understanding of BEAM semantics would have gotten them 90% of the gains with none of the complexity. The runtime is smarter than we often give it credit for—our job is to write code that lets it show off.
At NameOcean, we're always thinking about performance at scale. Whether you're spinning up Vibe Hosting for your next project or managing DNS for a high-traffic application, understanding these fundamentals helps you build systems that stay fast when things get busy.
Happy coding, and may your binaries always append efficiently.
Read in other languages: