# OTP, concurrency and performance

Reviewed: 2026-10-06.

Start with functions. Add processes for state ownership, concurrency or lifecycle. Choose shared storage by its read/write pattern; measure before changing the architecture.

Built-in Elixir and Erlang tools need no extra package. Follow the installed Elixir/OTP documentation; node-local memory and supervision do not provide durable storage.

## Computation and process state

### Ordinary computation: Functions + Enum

Reach for it when: The work is business logic or a transformation that needs no independent lifecycle.

Tradeoff: Keep it in functions. Adding a process adds messaging and coordination costs; it does not make a function faster.

Sources: [Functions + Enum documentation](https://hexdocs.pm/elixir/Enum.html)

### Lazy processing: Stream + File.stream!

Reach for it when: A file or collection should be processed incrementally, without building every intermediate result in memory.

Tradeoff: Streams are lazy: enumerate them to run the work. They are not parallel; collecting all results can still exhaust memory.

Sources: [Stream + File.stream! documentation](https://hexdocs.pm/elixir/Stream.html)

### One concurrent operation: Task

Reach for it when: Short-lived work can run alongside its caller, such as fetching independent inputs.

Tradeoff: Task.async links failures to the caller. Await the result and define timeout/cleanup behavior. Tasks do not survive node restarts as durable jobs.

Sources: [Task documentation](https://hexdocs.pm/elixir/Task.html)

### Supervised concurrent work: Task.Supervisor

Reach for it when: Tasks need an application-owned supervisor or must fail independently of the requesting process.

Tradeoff: async still links to the caller; choose async_nolink when isolation is required and handle results/exits. max_children rejects excess starts; it is not a waiting queue or a rate limiter.

Sources: [Task.Supervisor documentation](https://hexdocs.pm/elixir/Task.Supervisor.html)

### Bounded parallel batches: Task.async_stream

Reach for it when: Independent items can be processed concurrently with a bounded number of tasks.

Tradeoff: Enumerate the stream; set concurrency and timeout policy. ordered: false avoids buffering for input order when order is unnecessary. Task.Supervisor.async_stream_nolink adds supervised, unlinked task execution.

Sources: [Task.async_stream documentation](https://hexdocs.pm/elixir/Task.html#async_stream/3)

### Simple shared process state: Agent

Reach for it when: One process needs to hold a small value accessed through get/update functions.

Tradeoff: This is Elixir Agent, not an AI agent. Operations run serially inside its process. Choose GenServer for messages, timers or a richer lifecycle; ETS for concurrent table access.

Sources: [Agent documentation](https://hexdocs.pm/elixir/Agent.html)

### State and coordination: GenServer

Reach for it when: A long-lived process owns state or coordinates a resource, for example a connection or session.

Tradeoff: Callbacks execute serially. Keep slow work out of callbacks when responsiveness matters; call timeouts do not cancel server work. In-memory state needs a separate persistence strategy.

Sources: [GenServer documentation](https://hexdocs.pm/elixir/GenServer.html)

### Explicit runtime transitions: :gen_statem

Reach for it when: A protocol or connection has distinct states, state-specific events and transition/timeout rules.

Tradeoff: Use functions or a data model for ordinary business statuses. The behaviour models a running process; supervision does not make its state durable.

Sources: [:gen_statem documentation](https://www.erlang.org/doc/apps/stdlib/gen_statem.html)

## Lifecycle, naming and coordination

### Application lifecycle: Application + Supervisor

Reach for it when: Long-running children must start in order and recover according to their dependencies.

Tradeoff: Choose restart and shutdown policies deliberately. Restarts do not restore lost state, and terminate callbacks are not a durability guarantee. Slow child initialization delays later children.

Sources: [Application + Supervisor documentation](https://hexdocs.pm/elixir/Supervisor.html)

### Workers created at runtime: DynamicSupervisor

Reach for it when: The application starts and stops workers as demand changes, such as one worker per active room.

Tradeoff: Use Registry for discovery. A restarted supervisor does not remember the former dynamic child set; reconcile it from an authoritative source when required.

Sources: [DynamicSupervisor documentation](https://hexdocs.pm/elixir/DynamicSupervisor.html)

### Find local processes: Registry

Reach for it when: Processes need names based on runtime keys, such as tenant/session IDs, or local duplicate-key dispatch.

Tradeoff: Use :via tuples rather than creating atoms from user input. Registry is node-local; it is neither cluster discovery nor durable storage.

Sources: [Registry documentation](https://hexdocs.pm/elixir/Registry.html)

### Reduce supervisor contention: PartitionSupervisor

Reach for it when: Measurements show one process, such as a DynamicSupervisor or Task.Supervisor, bottlenecking concurrent starts.

Tradeoff: Route work across partitioned children. This reduces contention in that layer; it does not parallelize a single worker or distribute work across nodes.

Sources: [PartitionSupervisor documentation](https://hexdocs.pm/elixir/PartitionSupervisor.html)

### Observe failure and schedule messages: Process monitors + timers

Reach for it when: A process must observe another process exiting or receive a delayed message.

Tradeoff: Monitors report DOWN without coupling failures; links couple lifecycles. Process.send_after schedules a message, not a durable job. Account for stale timer messages and mailbox growth.

Sources: [Process monitors + timers documentation](https://hexdocs.pm/elixir/Process.html)

### Inspect a running process: :sys + Process.info

Reach for it when: A running server has unexpected state, memory growth or a growing mailbox.

Tradeoff: Use :sys for OTP process state and bounded debugging; Process.info reports process memory and queue length. Inspecting full state can expose sensitive values and allocate large results. These tools diagnose behavior; they do not repair it.

Sources: [OTP sys](https://www.erlang.org/doc/apps/stdlib/sys.html) · [Process.info](https://hexdocs.pm/elixir/Process.html#info/2)

## Shared data and hot paths

### Mutable shared tables: :ets

Reach for it when: Many processes need concurrent lookups or updates to node-local in-memory data.

Tradeoff: Choose table type, ownership and access rights. Tune read/write concurrency against measurements. The table normally dies with its owner; TTL, invalidation and durability are separate.

Sources: [:ets documentation](https://www.erlang.org/doc/apps/stdlib/ets.html)

### Read-mostly shared values: :persistent_term

Reach for it when: Many processes repeatedly read a value that changes rarely, such as a static lookup or a stable resource reference. A stable :counters reference can be stored once while the counter array itself is updated.

Tradeoff: Reads avoid locks and copying the value onto the caller heap. Writes copy the key table; replacing/deleting complex terms triggers a node-wide GC scan. Avoid request-time updates and expiring caches. Persistent means VM lifetime, not disk durability.

Sources: [:persistent_term documentation](https://www.erlang.org/doc/apps/erts/persistent_term.html)

### Frequent shared increments: :counters

Reach for it when: Many processes update numeric metrics without routing each increment through one server.

Tradeoff: write_concurrency favors add/sub throughput with weaker read consistency. Choose atomics mode when those semantics do not fit. Fixed-width integers wrap; this is node-local memory, not an accounting ledger.

Sources: [:counters documentation](https://www.erlang.org/doc/apps/erts/counters.html)

### Atomic numeric coordination: :atomics

Reach for it when: An integer operation needs an atomic result or compare-and-exchange, such as claiming a numeric slot.

Tradeoff: Individual operations are atomic and mutually ordered. A sequence of operations is not a transaction. Integers wrap on overflow, and these values are not cluster-wide.

Sources: [:atomics documentation](https://www.erlang.org/doc/apps/erts/atomics.html)

### FIFO data structure: :queue

Reach for it when: A process holds a FIFO buffer and repeated list appends would be costly.

Tradeoff: A queue is a data structure, not a worker, backpressure mechanism or persistent job system. Bound its size and define overload behavior.

Sources: [:queue documentation](https://www.erlang.org/doc/apps/stdlib/queue.html)

### Build output from fragments: iodata

Reach for it when: Output combines many byte fragments and its destination accepts iodata.

Tradeoff: Keep binaries and byte lists nested until a binary is required. Avoid repeated copying; benchmark the actual construction because BEAM binary appends can also be optimized.

Sources: [iodata documentation](https://hexdocs.pm/elixir/IO.html#module-io-data)

### Control retained binary memory: :binary

Reach for it when: Small slices of large payloads live in process state or another long-lived store.

Tradeoff: Inspect referenced_byte_size. Copy selected retained slices when they pin a much larger binary; copying every binary adds allocation and work.

Sources: [:binary documentation](https://www.erlang.org/doc/apps/stdlib/binary.html)

## When a task is not enough

- [GenStage](optional.md): Producers and consumers need a custom event pipeline with demand-driven backpressure.
- [Flow](optional.md): A large enumerable needs parallel mapping, partitioned grouping, aggregation or windows.
- [Broadway](optional.md): The application consumes a stream from Kafka, RabbitMQ, SQS or another supported source.

## Choose durability separately

Use Task for disposable work and Oban for application jobs that need persistence and retries. GenStage and Broadway regulate ingestion; source acknowledgements and retry-safe handlers determine delivery behavior.

Sources: [Oban](https://hexdocs.pm/oban/Oban.html)

## Measure the limiting resource

Track latency, throughput, memory, mailbox growth, database queries and pool waits on representative traffic. Extra tasks can overload a database or an upstream API. Use Benchee for code comparisons and profilers to find where work accumulates.

Sources: [Benchee](https://hexdocs.pm/benchee/readme.html) · [OTP profiling](https://www.erlang.org/doc/apps/tools/tprof.html)

## Stream database exports deliberately

Use Ecto.Repo.stream for incremental row processing when the adapter supports it. SQL adapters require enumeration inside a transaction. Long streams occupy a database connection; batching and collecting results can change memory use.

Sources: [Ecto.Repo.stream](https://hexdocs.pm/ecto/Ecto.Repo.html#c:stream/2)
