Prerequisites
ETS
Use ETS (Erlang Term Storage) for fast in-memory reads and concurrent shared state in Elixir. Covers table types, ownership, access modes, and production patterns.
ETS (Erlang Term Storage) is a built-in in-memory store optimized for high-throughput concurrent access. It is one of the most important tools for Elixir applications that need very fast lookups, counters, or caches.
If you have used GenServer state for everything, ETS is often the next step when reads become hot or when many processes need shared access.
What ETS Is Good For
Use ETS when you need:
- very fast lookups by key,
- shared data across many processes,
- counters or rolling aggregates,
- short-lived caches with predictable invalidation.
Avoid ETS when:
- data must survive restarts or deployments,
- relational queries are required,
- correctness depends on multi-step transactions (use Ecto/DB instead).
Creating a Table
defmodule MyApp.Cache do
@table :user_cache
def start_link(_opts) do
Task.start_link(fn ->
:ets.new(@table, [
:named_table,
:set,
:public,
read_concurrency: true,
write_concurrency: true
])
Process.sleep(:infinity)
end)
end
end
Common options:
:set,:ordered_set,:bag,:duplicate_bagtable types,:named_tablefor global name access,:public,:protected,:privateaccess control,read_concurrencyandwrite_concurrencyfor contention-heavy workloads.
Core Operations
# Insert or replace
:ets.insert(:user_cache, {"u_123", %{name: "Alice", tier: :pro}})
# Lookup always returns a list
case :ets.lookup(:user_cache, "u_123") do
[{"u_123", user}] -> {:ok, user}
[] -> :error
end
# Delete by key
:ets.delete(:user_cache, "u_123")
# Atomic counter
:ets.update_counter(:request_counts, "/api/search", 1, {"/api/search", 0})
Ownership and Supervision
Each ETS table has an owner process. If the owner exits, the table disappears.
This is the #1 production pitfall.
A common pattern is to create tables in a dedicated supervised process:
defmodule MyApp.ETSTables do
use GenServer
def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
@impl true
def init(:ok) do
:ets.new(:user_cache, [:named_table, :set, :protected, read_concurrency: true])
:ets.new(:request_counts, [:named_table, :set, :public, write_concurrency: true])
{:ok, %{}}
end
end
This keeps lifecycle explicit and restart behavior predictable.
ETS + GenServer Pattern
A pragmatic split:
- GenServer handles writes, invalidation, and lifecycle policy.
- ETS handles read-heavy access directly from many callers.
This reduces mailbox pressure on the GenServer while keeping update rules centralized.
# Python dict cache (single-process memory)
cache = {}
cache["u_123"] = {"name": "Alice"}
user = cache.get("u_123")
// Node.js Map cache (single runtime instance)
const cache = new Map();
cache.set("u_123", { name: "Alice" });
const user = cache.get("u_123");
# ETS cache (shared across BEAM processes)
:ets.insert(:user_cache, {"u_123", %{name: "Alice"}})
case :ets.lookup(:user_cache, "u_123") do
[{_, user}] -> user
[] -> nil
end
Common Mistakes
- Creating tables in short-lived request processes.
- Using
:publicwhen writes should be controlled. - Forgetting eviction/invalidation strategy.
- Storing unbounded data with no memory guardrails.
Choosing Table Types Quickly
:set: one value per key (most common).:ordered_set: sorted keys.:bag: multiple unique values per key.:duplicate_bag: duplicate values per key.
Exercise
Build a Read-Optimized Profile Cache
Implement a cache layer for user profiles:
- Create an ETS table owned by a supervised process.
- Add
get_profile/1,put_profile/2, anddelete_profile/1functions. - Implement a miss path that fetches from your data source and writes back to ETS.
- Add a simple TTL strategy by storing
{value, inserted_at}tuples. - Measure lookup latency before and after caching.
When done, continue to Task and Task.Supervisor to run concurrent workloads safely on top of these data patterns.
Related Lessons
Key Takeaways
- ETS gives you high-performance shared in-memory storage without centralizing all reads through one process
- Table ownership and supervision matter as much as table schema
- Choosing the right table type and access mode prevents subtle correctness and performance issues