When building concurrent systems in Elixir, you have several OTP abstractions available. Two of the most commonly discussed are Agent and GenServer, but in real systems developers also frequently use Task, ETS, and sometimes GenStage or Broadway.
Understanding when to use each abstraction is important for building systems that remain simple, scalable, and maintainable.
This article explains the differences and ends with a common GenServer anti-pattern to avoid.
Agent: a simple state container
An Agent is the simplest abstraction for managing shared state in a process.
It wraps a process that holds state and provides helper functions to read or update that state.
Example:
{:ok, pid} = Agent.start_link(fn -> [] end)
Agent.update(pid, fn state -> ["hello" | state] end)
Agent.get(pid, fn state -> state end)
Characteristics:
- Stores state in a separate process
- Minimal API (
getandupdate) - No message handling
- No lifecycle callbacks
- Very small abstraction
Typical use cases:
- Small in-memory caches
- Counters
- Temporary shared state
- Test helpers
Example:
Agent.start_link(fn -> %{} end, name: MyCache)
Agent.update(MyCache, &Map.put(&1, key, value))
Agent.get(MyCache, &Map.get(&1, key))
An Agent is essentially a lightweight wrapper around a process holding state.
GenServer: a full OTP server abstraction
A GenServer is a behaviour for implementing long-running server processes.
It provides a structured way to handle messages, maintain state, and react to events.
Example:
defmodule Counter do
use GenServer
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment do
GenServer.cast(__MODULE__, :increment)
end
def value do
GenServer.call(__MODULE__, :value)
end
def init(state) do
{:ok, state}
end
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
def handle_call(:value, _from, state) do
{:reply, state, state}
end
end
Characteristics:
- Structured callbacks (
init,handle_call,handle_cast,handle_info) - Supports synchronous and asynchronous communication
- Integrates with supervision trees
- Can schedule work and handle system messages
Typical use cases:
- Stateful services
- Resource managers
- Background workers
- Caches with logic
- Rate limiters
- Schedulers
A GenServer is best thought of as a stateful actor that encapsulates behaviour.
Why many developers skip Agent
Although Agents are simple, many production systems grow beyond their capabilities.
Two common limitations are:
Business logic leaks outside the process
With Agents, the caller often contains the business logic:
Agent.update(cache, fn state ->
Map.update(state, key, 1, &(&1 + 1))
end)
With a GenServer, the process owns the behaviour:
def increment(key) do
GenServer.cast(__MODULE__, {:increment, key})
end
def handle_cast({:increment, key}, state) do
{:noreply, Map.update(state, key, 1, &(&1 + 1))}
end
This makes the process behave like a service with a well-defined API.
Limited extensibility
Real systems often need:
- periodic work
- cache expiration
- telemetry
- retries
- batching
These are difficult to implement with Agents but natural in a GenServer.
Because of this, many developers default to GenServer.
Task: concurrency for short-lived work
Task is designed for temporary concurrent work.
Example:
task = Task.async(fn -> fetch_feed(url) end)
Task.await(task)
For parallel workloads:
urls
|> Task.async_stream(&fetch_feed/1, max_concurrency: 10)
|> Enum.to_list()
Typical use cases:
- parallel HTTP requests
- data processing
- CPU-bound work
- concurrent API calls
Tasks should be used for short-lived processes, not long-running services.
ETS: extremely fast shared memory
ETS (Erlang Term Storage) is an in-memory storage system optimized for concurrent access.
Example:
:ets.new(:cache, [:set, :public, :named_table])
:ets.insert(:cache, {:key, value})
:ets.lookup(:cache, :key)
Characteristics:
- extremely fast
- concurrent reads
- shared memory
- no process bottleneck
A common pattern is:
GenServer
↓
ETS table
The GenServer manages lifecycle and policies, while ETS stores the data.
A common GenServer anti-pattern
One of the most common mistakes in Elixir systems is turning a GenServer into a global bottleneck.
Example:
def handle_call({:fetch_url, url}, _from, state) do
result = HTTP.get(url)
{:reply, result, state}
end
Problem:
The GenServer becomes responsible for slow work such as:
- HTTP requests
- file IO
- database queries
Because a GenServer processes one message at a time, every request queues behind the previous one.
This can severely limit concurrency.
Better approach
Use the GenServer for coordination, not heavy work.
Example:
def handle_cast({:fetch_url, url}, state) do
Task.start(fn -> fetch_and_store(url) end)
{:noreply, state}
end
Now the GenServer remains responsive while tasks perform the expensive work.
Choosing the right abstraction
A simple decision guide:
| Problem | Recommended abstraction |
|---|---|
| Simple shared state | Agent |
| Stateful service or coordination | GenServer |
| Parallel short-lived work | Task |
| Ultra-fast shared memory | ETS |
| Streaming pipelines | GenStage |
| Message ingestion pipelines | Broadway |
Conclusion
Elixir provides multiple abstractions for building concurrent systems, each designed for a specific purpose.
In practice:
GenServeris the most common building blockTaskhandles concurrent workETSprovides high-performance shared memoryAgentis useful for very small state containers
Choosing the correct abstraction helps avoid bottlenecks and keeps systems simple as they grow.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.