741 words, 4 min read

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 (get and update)
  • 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:

  • GenServer is the most common building block
  • Task handles concurrent work
  • ETS provides high-performance shared memory
  • Agent is useful for very small state containers

Choosing the correct abstraction helps avoid bottlenecks and keeps systems simple as they grow.