# Sub-binaries keep the whole payload in memory

Slicing a binary in Elixir does not copy it. `binary_part/3` on a 1 MB payload hands you 100 bytes that quietly reference all 1 MB — and the parent cannot be garbage collected while your 100-byte slice is alive. This is the leak that shows up in log parsers, CSV importers, and any GenServer that pulls a field out of a large response and holds on to it.

```elixir
payload = :binary.copy("x", 1_000_000)

# ❌ Sub-binary: 100 bytes of data, 1 MB pinned
field = binary_part(payload, 0, 100)
byte_size(field)
# => 100
:binary.referenced_byte_size(field)
# => 1000000

# ✅ Copied: 100 bytes of data, 100 bytes pinned
field = :binary.copy(binary_part(payload, 0, 100))
byte_size(field)
# => 100
:binary.referenced_byte_size(field)
# => 100
```

`:binary.referenced_byte_size/1` is what makes this visible. It reports the size of the binary actually being referenced rather than the size of your term, so it tells you what the BEAM is really holding.

## The 64-byte cliff

The behaviour is not uniform, which is why it is so easy to miss. Results of 64 bytes or fewer are copied onto the process heap. Anything larger becomes a sub-binary pointing at the parent.

```elixir
big = :binary.copy("x", 10_000_000)

for n <- [64, 65] do
  slice = binary_part(big, 0, n)
  IO.puts("#{n} bytes -> references #{:binary.referenced_byte_size(slice)}")
end

# 64 bytes -> references 64
# 65 bytes -> references 10000000
```

A short status code stays cheap. A user agent, a URL, or a JSON field crosses the threshold and drags its entire payload along. Test fixtures with short values will never reproduce what production does.

## What it costs in aggregate

One retained parent is survivable. A list of them is not.

```elixir
defmodule Extract do
  def payload, do: :binary.copy("x", 1_000_000)

  def slice(payload), do: binary_part(payload, 0, 100)

  def copy(payload), do: :binary.copy(binary_part(payload, 0, 100))
end

measure = fn fun ->
  :erlang.garbage_collect()
  before = :erlang.memory(:binary)
  kept = Enum.map(1..50, fn _ -> fun.(Extract.payload()) end)
  :erlang.garbage_collect()
  retained = div(:erlang.memory(:binary) - before, 1024)

  # returning `kept` keeps it alive across the measurement
  {length(kept), retained}
end

{_, leaked} = measure.(&Extract.slice/1)
{_, copied} = measure.(&Extract.copy/1)

IO.puts("50 sub-binaries retain: #{leaked} KB")
IO.puts("50 copied binaries retain: #{copied} KB")

# 50 sub-binaries retain: 48830 KB
# 50 copied binaries retain: 7 KB
```

Fifty extracted fields of 100 bytes each — 5 KB of actual data — hold 48830 KB. Copying them first brings that to 7 KB.

## Where it actually bites

The leak needs somewhere long-lived to hide. Process state is the usual host:

```elixir
defmodule Ingest do
  use GenServer

  @impl GenServer
  def handle_cast({:record, response}, state) do
    # ❌ pins the entire response body for the life of the process
    # request_id = binary_part(response, 0, 128)

    # ✅ copy on the way into long-lived state
    request_id = :binary.copy(binary_part(response, 0, 128))

    {:noreply, %{state | seen: [request_id | state.seen]}}
  end
end
```

The same applies to anything you write into ETS, a `:persistent_term`, or a Registry value. Copy at the boundary where a value stops being transient.

The rule is narrow enough to apply without thinking: if a slice of a large binary outlives the function that produced it, run it through `:binary.copy/1` first. Inside a pipeline that discards the parent immediately, sub-binaries are exactly what you want — avoiding the copy is why binary pattern matching is fast in the first place.

[`:binary.referenced_byte_size/1` docs](https://www.erlang.org/doc/apps/stdlib/binary.html#referenced_byte_size/1) · [Erlang Efficiency Guide: binary handling](https://www.erlang.org/doc/system/binaryhandling.html)


---

Created by: almirsarajcic
Date: August 20, 2026
URL: https://elixirdrops.net/d/PsC3WBxR
