<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[BEAM In The Wild]]></title><description><![CDATA[Deep dives into Elixir, BEAM internals, and distributed systems. Real findings, the reasoning behind them, and practical tools to handle them in your own systems.]]></description><link>https://ntwaliheritier.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a07761cc013adcbdaffb1c4/aa36c7c8-fdbd-409b-b49d-a9ea12b69185.png</url><title>BEAM In The Wild</title><link>https://ntwaliheritier.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 10:19:17 GMT</lastBuildDate><atom:link href="https://ntwaliheritier.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Beam in the Wild #4: Routing and Replication Are Two Different Problems: libring and delta_crdt]]></title><description><![CDATA[libring and delta_crdt show up together in almost every distributed Elixir example, and it's tempting to treat them as one package. They're not. libring…]]></description><link>https://ntwaliheritier.hashnode.dev/beam-in-the-wild-4-routing-and-replication-are-two-different-problems-libring-and-delta-crdt</link><guid isPermaLink="true">https://ntwaliheritier.hashnode.dev/beam-in-the-wild-4-routing-and-replication-are-two-different-problems-libring-and-delta-crdt</guid><dc:creator><![CDATA[Ntwali Heritier]]></dc:creator><pubDate>Sun, 06 Sep 2026 23:56:16 GMT</pubDate><content:encoded><![CDATA[<p>libring and delta_crdt show up together in almost every distributed Elixir example, and it's tempting to treat them as one package. They're not. libring answers one question. delta_crdt answers a completely different one. Knowing where that line sits is what actually makes them usable together.</p>
<hr />
<h3>What libring actually does</h3>
<p>libring is a consistent hashing library. Given a set of nodes and a key, it answers exactly one question: which node is responsible for this key?</p>
<pre><code class="language-shell">ring =
  HashRing.new()
  |&gt; HashRing.add_nodes([:node1, :node2, :node3])

HashRing.key_to_node(ring, "USD/EUR")
# =&gt; :node2
</code></pre>
<p>That's the entire interface. libring doesn't store anything. It doesn't talk to the network. It doesn't know or care whether the node it names is alive, whether that node has any data for the key, or whether every other node in the cluster would give you the same answer right now. It's a pure function. Same ring, same key, same answer, every time, computed locally with zero coordination.</p>
<p>The reason this matters more than a plain <code>hash(key) rem length(nodes)</code> is what happens when the node list changes. With modulo hashing, adding a single node reshuffles almost every key's owner. A routine deploy becomes a mass, unjustified reshuffle. Consistent hashing hashes both nodes and keys onto the same ring; adding a node only takes over the keys between it and its neighbour on the ring. Everyone else's ownership is untouched.</p>
<hr />
<h3>What delta_crdt actually does</h3>
<p>delta_crdt solves a completely different problem: how do multiple copies of the same data agree on a value, without a coordinator, without a leader, and without blocking on a network round trip.</p>
<p>Every node runs its own local <code>delta_crdt</code> process holding a full copy of the state. Writes happen locally, immediately, with no need to ask permission from anyone else. Periodically, each node gossips its recent changes to its configured neighbours, and the CRDT's merge function combines them, so every node converges on the same final state regardless of the order updates arrive in.</p>
<pre><code class="language-shell">{:ok, crdt} = DeltaCrdt.start_link(DeltaCrdt.AWLWWMap, on_diffs: &amp;handle_diff/1)

DeltaCrdt.set_neighbours(crdt, [neighbour_pid_1, neighbour_pid_2])

DeltaCrdt.put(crdt, "USD/EUR", 1.08)
</code></pre>
<p><code>on_diffs</code> fires locally whenever new deltas arrive from a neighbour. This is how you'd mirror the CRDT's state into something faster to read, like ETS.</p>
<p>The important property: delta_crdt doesn't need a single owner per key. Any node can write any key at any time, and the merge guarantees convergence. That's what "conflict-free" means in the name, commutative, associative, idempotent merge. So no matter how updates arrive, the result is the same everywhere.</p>
<hr />
<h3>Why you need both</h3>
<p>If delta_crdt already handles writes from anywhere without a designated owner, it's fair to ask why libring is involved at all. Two reasons, both about scale rather than correctness.</p>
<p><strong>Avoiding "everyone stores everything."</strong> A single shared CRDT map, gossiped to every node, means every node holds a full copy of every key. Fine for a small exchange-rate cache. Not fine for a large keyspace across a big cluster. libring lets you shard. Run multiple CRDT instances, each covering a subset of keys, with the ring deciding which node hosts which shard.</p>
<p><strong>Fewer concurrent writers means fewer conflicts to resolve.</strong> Even within a single CRDT, if every node writes every key at any time, you get constant concurrent writes to the same keys. For a last-write-wins map, that means constant silent overwrites. Routing writes through the ring's designated owner most of the time means conflicts become the rare exception, only during partitions or membership drift, rather than the normal case.</p>
<p>So the honest division: <strong>libring decides who is allowed to write. delta_crdt decides what survives if that decision was ever ambiguous, and makes every node able to read the result locally regardless of who wrote it.</strong></p>
<hr />
<h3>A working example</h3>
<p>A ring-routed, distributed key-value store, storing the ring in <code>:persistent_term</code> for fast, lock-free reads on every request.</p>
<pre><code class="language-shell">defmodule CrdtDemo.Topology do
  @hash_ring :ring

  def owner(key) do
    :persistent_term.get(@hash_ring)
    |&gt; HashRing.key_to_node(key)
  end

  def add_nodes_to_ring(nodes) do
    ring =
      HashRing.new()
      |&gt; HashRing.add_nodes([node() | nodes])

    :persistent_term.put(@hash_ring, ring)
  end
end
</code></pre>
<p><code>:persistent_term</code> is the right fit here specifically because writes are rare (only on membership change) and reads happen on every request. Writes are expensive because they trigger a scan across every process in the VM. But that cost is paid only when the cluster topology actually changes, not on every lookup.</p>
<p>Membership changes are watched with <code>:net_kernel.monitor_nodes/1</code>, and both the ring and the CRDT's gossip neighbours are kept current together:</p>
<pre><code class="language-shell">defmodule CrdtDemo.Ring do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init(_) do
    :net_kernel.monitor_nodes(true)
    sync()
    {:ok, []}
  end

  def handle_info({:nodeup, _}, state) do
    sync()
    {:noreply, state}
  end

  def handle_info({:nodedown, _}, state) do
    sync()
    {:noreply, state}
  end

  defp sync() do
    nodes = Node.list()
    CrdtDemo.Topology.add_nodes_to_ring(nodes)
    CrdtDemo.Store.set_neighbours(nodes)
  end
end
</code></pre>
<p>The store wraps a <code>delta_crdt</code> instance, mirrors its state into ETS for fast local reads, and routes writes to whichever node the ring names as owner:</p>
<pre><code class="language-shell">defmodule CrdtDemo.Store do
  use GenServer

  @table :store_mirror
  @crdt_pid_key :store_crdt_pid

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  def get_crdt_pid, do: :persistent_term.get(@crdt_pid_key)

  def set_neighbours(nodes) do
    results = :erpc.multicall(nodes, __MODULE__, :get_crdt_pid, [])
    crdt_pids =
        Enum.reduce(results, [], fn
          {:ok, pid}, acc -&gt; [pid | acc]
          _error, acc -&gt; acc
        end)

    DeltaCrdt.set_neighbours(get_crdt_pid(), crdt_pids)
  end

  def put(key, value) do
    case CrdtDemo.Topology.owner(key) do
      node when node == node() -&gt; update_crdt(key, value)
      other_node -&gt; :erpc.call(other_node, __MODULE__, :put, [key, value])
    end
  end

  def update_crdt(key, value) do
    get_crdt_pid() |&gt; DeltaCrdt.put(key, value)
  end

  def get(key) do
    case :ets.lookup(@table, key) do
      [{^key, value}] -&gt; value
      [] -&gt; nil
    end
  end

  def init(_) do
    {:ok, crdt} = DeltaCrdt.start_link(DeltaCrdt.AWLWWMap, on_diffs: &amp;handle_diff/1)
    :persistent_term.put(@crdt_pid_key, crdt)
    :ets.new(@table, [:named_table, :public, read_concurrency: true])
    {:ok, %{crdt: crdt}}
  end

  defp handle_diff(diffs) do
    Enum.each(diffs, fn
      {:add, key, value} -&gt; :ets.insert(@table, {key, value})
      {:remove, key} -&gt; :ets.delete(@table, key)
    end)
  end
end
</code></pre>
<p>Notice <code>get/1</code> never consults the ring at all. It reads whatever's in this node's own ETS table. That works because <code>handle_diff/1</code> runs on every node as gossip arrives, continuously mirroring the CRDT's state into local ETS. By the time you call <code>get/1</code> anywhere, the value is very likely already sitting in local memory. The ring decides where writes are accepted. It has nothing to do with where reads are served.</p>
<hr />
<h3>Trying it yourself</h3>
<p>Both <code>Ring</code> and <code>Store</code> are added as children in <code>application.ex</code>, so they start automatically on boot:</p>
<pre><code class="language-shell">def start(_type, _args) do
  children = [
    CrdtDemo.Store,
    CrdtDemo.Ring
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: CrdtDemo.Supervisor)
end
</code></pre>
<p><code>Store</code> before <code>Ring</code> matters. <code>Ring.init/1</code> calls <code>sync/0</code> immediately, which calls <code>Store.set_neighbours/1</code>. <code>Store</code> needs to exist first.</p>
<p>Start three named nodes locally:</p>
<pre><code class="language-shell">iex --sname node1 --cookie demo -S mix
iex --sname node2 --cookie demo -S mix
iex --sname node3 --cookie demo -S mix
</code></pre>
<p>Connect them from any one node.<br />The example below is from node 1.</p>
<pre><code class="language-shell">Node.connect(:"node2@yourhostname")
Node.connect(:"node3@yourhostname")
</code></pre>
<p>Each node's <code>Ring</code> process gets a <code>:nodeup</code> message as the others join, rebuilds the ring, and re-establishes CRDT neighbours. Write a value from one node:</p>
<pre><code class="language-shell">CrdtDemo.Store.put("USD/EUR", 1.08)
</code></pre>
<p>Read it back from a different node entirely:</p>
<pre><code class="language-shell">CrdtDemo.Store.get("USD/EUR")
# =&gt; 1.08
</code></pre>
<p>Nothing was asked of the writing node to get that answer. The value arrived via gossip and is sitting in this node's own ETS table.</p>
<p>Kill one node (<code>Ctrl+C</code> twice, or <code>System.stop()</code>) and the remaining two get <code>:nodedown</code>, rebuild their ring without it, and keep working. Bring the node back up and reconnect it. Its <code>Ring.init/1</code> runs again, <code>sync/0</code> fires, and it picks up the current ring and re-establishes neighbours. Read the same key from the node that just rejoined:</p>
<pre><code class="language-shell">CrdtDemo.Store.get("USD/EUR")
# =&gt; 1.08
</code></pre>
<p>It has the value again, without you doing anything to restore it. Gossip caught it up once neighbours were re-established. That's the whole point made concrete: the node's own local state was gone the moment it went down, and it came back purely by rejoining the mesh.</p>
<hr />
<h3>The one thing worth remembering</h3>
<p>libring's ownership answer is computed from each node's own local view of cluster membership <code>Node.list()</code> at the moment <code>sync/0</code> runs. That view isn't guaranteed to be identical across every node at every instant, especially during a deploy or a brief network hiccup. libring doesn't coordinate that away, and it isn't supposed to.</p>
<p>That's exactly the gap delta_crdt exists to close. If two nodes briefly hold different rings and both accept a write for the same key, both writes survive as deltas and merge deterministically once gossip catches up. The ring is the fast, common-case routing decision. The CRDT is what makes it safe for that decision to occasionally be wrong.</p>
]]></content:encoded></item><item><title><![CDATA[Beam in the Wild #3: Three Limits, One Fix. Why Raising ulimit Is Not Enough]]></title><description><![CDATA[New connections stop arriving. Everything else looks normal, existing users, CPU, memory, :observer. The obvious fix is ulimit -n. But there are three…]]></description><link>https://ntwaliheritier.hashnode.dev/beam-in-the-wild-3-three-limits-one-fix-why-raising-ulimit-is-not-enough</link><guid isPermaLink="true">https://ntwaliheritier.hashnode.dev/beam-in-the-wild-3-three-limits-one-fix-why-raising-ulimit-is-not-enough</guid><dc:creator><![CDATA[Ntwali Heritier]]></dc:creator><pubDate>Sun, 21 Jun 2026 21:38:33 GMT</pubDate><content:encoded><![CDATA[<p>New connections stop arriving. Everything else looks normal, existing users, CPU, memory, <code>:observer</code>. The obvious fix is <code>ulimit -n</code>. But there are three limits that need to move, and raising only one leaves the others as the ceiling.</p>
<hr />
<h3>What File Descriptors Are</h3>
<p>The OS gives every process a budget of numbered tokens called file descriptors, and every connection costs one. TCP connections, open files, pipes, Unix sockets, all consume from the same budget. When the budget runs out, new connections are silently turned away. Existing connections are unaffected. Everything else looks normal.<br />The default budget on most Linux systems is 1,024. Check yours:</p>
<pre><code class="language-plaintext">ulimit -n
</code></pre>
<p>At 1,024 your BEAM server stops accepting new connections. Ranch logs the error <code>{:error, :emfile}</code>, but nothing else looks wrong. CPU normal, memory normal, scheduler utilization normal, existing connections unaffected. The error is easy to miss unless you have alerting on it. New users can't get in. Current users notice nothing.</p>
<hr />
<h3>The First Hidden Limit: +Q</h3>
<p><code>ulimit -n</code> is the OS-level limit. How many file descriptors the process is allowed to hold. Raise it and the OS will allow more.</p>
<p>But the BEAM has its own internal limit: <code>+Q</code>. It controls how many ports the VM will open, regardless of what the OS allows.</p>
<pre><code class="language-plaintext">effective limit = min(ulimit -n, +Q)
</code></pre>
<p>Raise only <code>ulimit -n</code> and <code>+Q</code> stays as the ceiling. The system still runs out at the BEAM's limit. Raise only <code>+Q</code> and <code>ulimit -n</code> stays as the ceiling. Same result.</p>
<p>Both must move together:</p>
<pre><code class="language-plaintext"># Check the OS limit
ulimit -n

# Check the system-wide ceiling on Linux, ulimit -n cannot exceed this
cat /proc/sys/fs/file-max

# Check what the BEAM is actually using
# From inside a running node:
:erlang.system_info(:port_limit)   # the +Q ceiling
:erlang.system_info(:port_count)   # current usage
</code></pre>
<p>65,536 is the standard production value. Both the OS and the BEAM handle it comfortably on any modern server, no additional system configuration needed.</p>
<p>In <code>vm.args</code>:</p>
<pre><code class="language-plaintext">+Q 65536
</code></pre>
<p>Set it slightly below <code>ulimit -n</code>, the BEAM uses a small number of ports for internal purposes and <code>+Q</code> needs headroom above your application's usage.</p>
<hr />
<h3>The Second Hidden Limit: Containers</h3>
<p>Fix both limits locally, deploy to Docker or ECS, and the problem comes back.</p>
<p>Containers do not inherit the host's <code>ulimit</code>. Each container gets its own resource limits, and the default is usually whatever the base image ships with.</p>
<p>The host can have <code>ulimit -n 65536</code> and every container running on it will still cap out at 1,024 unless you set the container's limit explicitly.</p>
<p><strong>Docker:</strong></p>
<pre><code class="language-yaml"># docker-compose.yml
services:
  app:
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
</code></pre>
<p><strong>ECS task definition:</strong></p>
<pre><code class="language-json">"ulimits": [
  {
    "name": "nofile",
    "softLimit": 65536,
    "hardLimit": 65536
  }
]
</code></pre>
<hr />
<h3>Three Layers, All Must Move</h3>
<pre><code class="language-plaintext">Host OS ulimit       ← set in /etc/security/limits.conf or systemd LimitNOFILE
      ↓
Container ulimit     ← set in docker-compose or ECS task definition
      ↓
BEAM +Q              ← set in vm.args
</code></pre>
<p>The effective limit is always the lowest of the three. Fix two and leave one at the default and the system hits the same wall.</p>
<p><strong>Verification after any change:</strong></p>
<pre><code class="language-plaintext"># Find the BEAM process ID
pgrep beam.smp

# Then check its limits
cat /proc/&lt;pid&gt;/limits | grep "open files"

# From inside the running BEAM node
:erlang.system_info(:port_limit)
:erlang.system_info(:port_count)
</code></pre>
<p>These two numbers should tell the same story. If <code>port_limit</code> is lower than your <code>ulimit</code>, <code>+Q</code> wasn't set. If <code>port_limit</code> is right but connections are still refused, the container's <code>ulimit</code> wasn't set.</p>
<hr />
<h3>The Production Configuration</h3>
<pre><code class="language-plaintext"># /etc/security/limits.conf (or systemd: LimitNOFILE=65536)
* soft nofile 65536
* hard nofile 65536
</code></pre>
<pre><code class="language-plaintext"># vm.args
+Q 65536
</code></pre>
<pre><code class="language-json">// ECS task definition
"ulimits": [{ "name": "nofile", "softLimit": 65536, "hardLimit": 65536 }]
</code></pre>
<p>65536 is the standard production value. It's high enough for most workloads and well within OS and BEAM defaults.</p>
<hr />
<h3>The One Thing To Remember</h3>
<p>When connections are being refused and everything else looks normal, check all three limits.</p>
]]></content:encoded></item><item><title><![CDATA[BEAM in the Wild #2: Keep Your GenServer Responsive with Task.Supervisor.async_nolink
]]></title><description><![CDATA[While building an ExchangeMonitor that fetches live exchange rates, load testing revealed a problem: callers were timing out. The GenServer was the…]]></description><link>https://ntwaliheritier.hashnode.dev/beam-in-the-wild-2-keep-your-genserver-responsive-with-task-supervisor-async-nolink</link><guid isPermaLink="true">https://ntwaliheritier.hashnode.dev/beam-in-the-wild-2-keep-your-genserver-responsive-with-task-supervisor-async-nolink</guid><dc:creator><![CDATA[Ntwali Heritier]]></dc:creator><pubDate>Sun, 14 Jun 2026 15:52:20 GMT</pubDate><content:encoded><![CDATA[<p>While building an ExchangeMonitor that fetches live exchange rates, load testing revealed a problem: callers were timing out. The GenServer was the bottleneck. It was doing slow HTTP work in the middle of its message processing loop, blocking everyone else. This post covers why that happens and how <code>Task.Supervisor.async_nolink</code> solves it.  </p>
<hr />
<h3>The Problem: GenServers process one message at a time</h3>
<p>A GenServer handles messages one at a time, in order. While it's handling one message, every other message waits in the mailbox.</p>
<p>This is fine until a message handler does something slow. The ExchangeMonitor fetches exchange rates from an external API on a timer:</p>
<pre><code class="language-plaintext">def handle_info(:refresh, state) do
  # HTTP call - blocks for 300-500ms
  rate = ExchangeAPI.fetch(state.currency_pair)
  schedule_next_refresh()
  {:noreply, %{state | rate: rate}}
end
</code></pre>
<p>While that HTTP call is running, the GenServer is frozen. A payment request that calls <code>GenServer.call(monitor, :get_rate)</code> has to wait in the mailbox for the entire duration of the fetch.  </p>
<p>At low traffic this is invisible. Under load, with many concurrent callers and a slow API, the mailbox grows. Callers hit their default 5-second timeout. The system looks broken.  </p>
<p>The root cause: slow work inside a GenServer callback.  </p>
<hr />
<h3>What Task.Supervisor.async_nolink Does</h3>
<p><code>Task.Supervisor.async_nolink</code> moves the slow work into a separate process. The GenServer spawns the task and returns immediately. It keeps processing messages while the fetch runs concurrently in the background.  </p>
<p>The <code>nolink</code> part matters. A regular <code>Task.async</code> creates a linked task. If the task crashes, the exit signal propagates to the GenServer and crashes it too. <code>async_nolink</code> removes that link. The task can fail without affecting the GenServer.  </p>
<p>When the task completes, it sends a message back to the GenServer(success or failure). The GenServer handles that message like any other.</p>
<pre><code class="language-plaintext">defmodule MyApp.ExchangeMonitor do
  use GenServer

  @refresh_interval :timer.seconds(30)

  def init(currency_pair) do
    {:ok, %{currency_pair: currency_pair, rate: nil, pending_ref: nil},
     {:continue, :initial_fetch}}
  end

  # handle_continue runs before any messages are processed
  def handle_continue(:initial_fetch, state) do
    {:noreply, spawn_fetch(state)}
  end

  def handle_info(:refresh, state) do
    schedule_refresh()

    if state.pending_ref do
      # Previous fetch still in flight — skip this tick
      {:noreply, state}
    else
      {:noreply, spawn_fetch(state)}
    end
  end

  # Task completed successfully
  def handle_info({ref, {:ok, rate}}, state) when state.pending_ref == ref do
    Process.demonitor(ref, [:flush])
    {:noreply, %{state | rate: rate, pending_ref: nil}}
  end

  # Task crashed
  def handle_info({:DOWN, ref, :process, _pid, reason}, state)
      when state.pending_ref == ref do
    Logger.warning("Exchange rate fetch failed: #{inspect(reason)}")
    {:noreply, %{state | pending_ref: nil}}
  end

  defp spawn_fetch(state) do
    task =
      Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn -&gt;
        ExchangeAPI.fetch(state.currency_pair)
      end)

    %{state | pending_ref: task.ref}
  end

  defp schedule_refresh do
    Process.send_after(self(), :refresh, @refresh_interval)
  end
end
</code></pre>
<p>A few things worth noting in this pattern:</p>
<p><code>pending_ref</code> <strong>prevents overlapping fetches.</strong> If the timer fires while a fetch is still in flight, the GenServer skips that tick. Without this, you can accumulate multiple concurrent fetches for the same currency pair.  </p>
<p><code>Process.demonitor(ref, [:flush])</code> clears the monitor on success and flushes any pending DOWN message from the queue. Without the flush, a DOWN message can arrive after you've already processed the result and confuse the state.</p>
<p><code>handle_continue</code> <strong>for the initial fetch.</strong> This guarantees the first fetch happens before any other message is processed. Without it, a caller could ask for the rate before it's ever been fetched.</p>
<hr />
<h3>When to Use Task.Supervisor.start_child</h3>
<p><code>async_nolink</code> is the right tool when you need the result back in the GenServer.</p>
<p><code>Task.Supervisor.start_child</code> is the right tool when you don't. The work happens, you never hear from it:</p>
<pre><code class="language-plaintext">Task.Supervisor.start_child(MyApp.TaskSupervisor, fn -&gt;
  Notifications.send_transfer_confirmation(transfer)
end)
</code></pre>
<p>No reference stored, no message handled, no state updated. If the task fails, the work is lost and the caller never knows. Use it for side effects where that's acceptable, i.e: sending notifications, warming a cache, writing to an audit log, ...  </p>
<p>The rule: if you need the result or need to know it failed, use <code>async_nolink</code>. If you genuinely don't care about the outcome, use <code>start_child</code>.</p>
]]></content:encoded></item><item><title><![CDATA[BEAM in the Wild #1: The Silent Memory Anchor Hiding in Your Elixir Binaries]]></title><description><![CDATA[I was digging into BEAM memory internals this week and found something that would have cost me real production pain if I'd hit it in a live system first…]]></description><link>https://ntwaliheritier.hashnode.dev/beam-in-the-wild-1-the-silent-memory-anchor-hiding-in-your-elixir-binaries</link><guid isPermaLink="true">https://ntwaliheritier.hashnode.dev/beam-in-the-wild-1-the-silent-memory-anchor-hiding-in-your-elixir-binaries</guid><dc:creator><![CDATA[Ntwali Heritier]]></dc:creator><pubDate>Sun, 07 Jun 2026 01:02:55 GMT</pubDate><content:encoded><![CDATA[<p>I was digging into BEAM memory internals this week and found something that would have cost me real production pain if I'd hit it in a live system first.</p>
<p>Extracting 10 bytes from a 10MB binary. The 10MB never left memory.</p>
<p>Here's what's actually happening, and how to find and fix it in production.</p>
<hr />
<h2>How Binaries Work in Elixir</h2>
<p>Not all binaries live in the same place. The BEAM splits them into two categories based on size.</p>
<p><strong>Small binaries (≤ 64 bytes)</strong> live directly on the process heap. They behave like any other data. They are copied when sent between processes, garbage collected when the process GCs.</p>
<p><strong>Large binaries (&gt; 64 bytes)</strong> live on a shared binary heap outside any process. The process doesn't hold the binary itself, it holds a <strong>ProcBin</strong>: a small reference-counted pointer to the shared heap.  </p>
<p>Passing a large binary between processes is cheap because only the pointer moves, not the data.  </p>
<p>The Discovery: Sub-Binaries Don't Copy</p>
<p>Here's what surprised me.</p>
<p>When you slice a large binary, you don't get an independent copy. You get a <strong>sub-binary</strong>(a small data structure on the process heap containing a pointer to the original, an offset, and a size).  </p>
<p>The original large binary stays alive on the shared heap as long as any sub-binary references it.</p>
<p>This applies to every common slicing operation:  </p>
<pre><code class="language-plaintext">large = File.read!("huge_file.json")  # 10MB on shared heap

# All of these create sub-binaries — not copies
&lt;&lt;header::binary-size(10), _rest::binary&gt;&gt; = large
binary_part(large, 0, 10)
String.slice(large, 0, 10)
String.split(large, ",")
String.trim(large)
:binary.split(large, ",")
</code></pre>
<p>The rule: <strong>any function that returns a portion of a large binary without explicitly copying creates a sub-binary.</strong> This is not a bug. It is an intentional optimisation. Copies are expensive. But it means the original binary anchors in memory longer than you expect.</p>
<hr />
<h2>Why It's Hard to Notice</h2>
<p>The trap is in what you see versus what's actually happening.</p>
<p>Your process heap looks small. The ProcBin is a few bytes. <code>:observer</code> shows low process memory. Everything looks fine.</p>
<p>Meanwhile, the shared binary heap is accumulating every large binary that has an active reference somewhere. No warning. No error. Just silent memory pressure building over time.</p>
<p>A GenServer that parses large payloads and stores a slice in state is the classic case:</p>
<pre><code class="language-plaintext">def handle_info({:http_response, body}, state) do
  # body is a 500KB binary
  # This extracts 20 bytes — but keeps the 500KB referenced
  &lt;&lt;header::binary-size(20), _::binary&gt;&gt; = body
  {:noreply, %{state | last_header: header}}
end
</code></pre>
<p>Every response keeps its full body alive on the shared heap until the process GCs and the ProcBin for <code>last_header</code> is swept. Under load, these accumulate.</p>
<hr />
<h2>Step-by-Step Diagnosis</h2>
<h3>Step 1. Add :recon</h3>
<p><code>:recon</code> is a production introspection library for the BEAM. Add it to your dependencies:</p>
<pre><code class="language-plaintext"># mix.exs
defp deps do
  [
    {:recon, "~&gt; 2.5"}
  ]
end
</code></pre>
<h3>Step 2. Find the offending processes</h3>
<p>Find which processes are holding the most binary memory:</p>
<pre><code class="language-plaintext">:recon.proc_count(:binary_memory, 10)
</code></pre>
<p>This returns the top 10 processes ranked by binary memory. Output looks like:</p>
<pre><code class="language-plaintext">[{#PID&lt;0.234.0&gt;, 4823042, [
  current_function: {MyApp.Worker, :handle_info, 2},
  initial_call: {MyApp.Worker, :init, 1},
  ...
]}, ...]
</code></pre>
<p>The first number is binary memory in bytes. If a process shows several megabytes here, it's your suspect.</p>
<h3>Step 3. Distinguish GC lag from a real leak</h3>
<p>Memory that looks like a leak might just be data that hasn't been GC'd yet. Force GC on the suspect process and check if memory drops:</p>
<pre><code class="language-plaintext"># Force GC
:erlang.garbage_collect(pid)

# Check memory again
:recon.proc_count(:binary_memory, 10)
</code></pre>
<p>Two outcomes:</p>
<ul>
<li><p><strong>Memory drops</strong> : GC lag. The data was already dead, just unswept. No design problem, the GC will handle it naturally under load.</p>
</li>
<li><p><strong>Memory stays</strong> : Real retention. Something in the process state is still holding a reference to a large binary. This is a design problem.</p>
</li>
</ul>
<h3>Step 4. Find all leaking processes in one shot</h3>
<pre><code class="language-plaintext">:recon.bin_leak(10)
</code></pre>
<p>Returns 10 processes currently retaining the most binary memory</p>
<h3>Step 5. Find what's holding the reference</h3>
<p>Once you have the PID, inspect its state.</p>
<pre><code class="language-plaintext">:sys.get_state(pid)
</code></pre>
<p>Look for fields that contain large binaries or sub-binaries. Common culprits:</p>
<ul>
<li><p>State fields storing slices of large payloads</p>
</li>
<li><p>ETS tables holding sub-binary values</p>
</li>
<li><p>Accumulated log buffers</p>
</li>
</ul>
<hr />
<h2>Resolution</h2>
<p>Once you've found the reference, the fix is <code>:binary.copy/1</code>. It creates a fully independent binary without connection to the original:  </p>
<pre><code class="language-plaintext"># Before — sub-binary anchors the full body
&lt;&lt;header::binary-size(20), _::binary&gt;&gt; = body
%{state | last_header: header}

# After — independent copy, original can be GC'd
&lt;&lt;header::binary-size(20), _::binary&gt;&gt; = body
%{state | last_header: :binary.copy(header)}
</code></pre>
<p>The same fix applies regardless of how the sub-binary was created:</p>
<pre><code class="language-plaintext"># Any of these:
slice = binary_part(large, 0, 10)
slice = String.slice(large, 0, 10)
[first | _] = String.split(large, ",")

# Wrap with :binary.copy/1 before storing
:binary.copy(slice)
</code></pre>
<h2>The Rule</h2>
<blockquote>
<p>Any function returning a portion of a large binary without explicitly copying creates a sub-binary that anchors the original in memory.</p>
</blockquote>
<p>When you need to store a slice of a large binary (in process state, in ETS, in a message), copy it first. Pay the copy cost once at the point of extraction. Everything downstream gets a clean, independent value with no hidden reference.</p>
]]></content:encoded></item></channel></rss>