
Elixir Performance Review
- 85 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
elixir-performance-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- elixir-performance-review
- AI & Agent Building
- AI-coding skill
Elixir Performance Review by the numbers
- 85 all-time installs (skills.sh)
- Ranked #5,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill elixir-performance-reviewAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Elixir Performance Review
Quick Reference
| Issue Type | Reference |
|---|---|
| Mailbox overflow, blocking calls | references/genserver-bottlenecks.md |
| When to use ETS, read/write concurrency | references/ets-patterns.md |
| Binary handling, large messages | references/memory.md |
| Task patterns, flow control | references/concurrency.md |
Review Checklist
GenServer
- [ ] Not a single-process bottleneck for all requests
- [ ] No blocking operations in handle_call/cast
- [ ] Proper timeout configuration
- [ ] Consider ETS for read-heavy state
Memory
- [ ] Large binaries not copied between processes
- [ ] Streams used for large data transformations
- [ ] No unbounded data accumulation
Concurrency
- [ ] Task.Supervisor for dynamic tasks (not raw Task.async)
- [ ] No unbounded process spawning
- [ ] Proper backpressure for message producers
Database
- [ ] Preloading to avoid N+1 queries
- [ ] Pagination for large result sets
- [ ] Indexes for frequent queries
Valid Patterns (Do NOT Flag)
- Single GenServer for low-throughput - Not all state needs horizontal scaling
- Synchronous calls for critical paths - Consistency may require it
- In-memory state without ETS - ETS has overhead for small state
- Enum over Stream for small collections - Stream overhead not worth it
Context-Sensitive Rules
| Issue | Flag ONLY IF |
|---|---|
| GenServer bottleneck | Handles > 1000 req/sec OR blocking I/O in callbacks |
| Use streams | Processing > 10k items OR reading large files |
| Use ETS | Read:write ratio > 10:1 AND concurrent access |
Gates — before reporting
Do these in order for the performance review. Do not publish findings until each step passes.
1. Protocol loaded — Read review-verification-protocol and apply its checks for each finding (hot paths, concurrency, resource use). Pass: For every substantive finding, you can name which protocol subsection you satisfied or state N/A with reason (e.g. pure reference to this skill’s Valid Patterns). 2. Anchored evidence — Pass: Each finding includes a concrete locator: path:line (or line range), or Module.function/arity plus a short quoted snippet from the file. 3. Performance claims — For anything under Context-Sensitive Rules, or any claim of bottleneck, N+1, unbounded growth, or heavy memory/binary cost, Pass: You state the observed or measured fact that meets “Flag ONLY IF” (e.g. rate, item count, ratio), or attach an artifact (profiler output, SQL/log excerpt, grep/search scope)—otherwise downgrade to question / suspected with what was not verified.
Before Submitting Findings
Complete Gates — before reporting (section above) first; the verification protocol is mandatory input to those gates.
Concurrency Patterns
Task Patterns
Use Task.Supervisor for Dynamic Tasks
# BAD - unlinked task, crashes silently
Task.start(fn -> risky_work() end)
# BAD - linked task, crashes caller if task crashes
Task.async(fn -> risky_work() end) |> Task.await()
# GOOD - supervised, restartable
Task.Supervisor.async_nolink(MyTaskSupervisor, fn ->
risky_work()
end)Parallel Processing
# Process items concurrently with limit
Task.Supervisor.async_stream_nolink(
MyTaskSupervisor,
items,
fn item -> process(item) end,
max_concurrency: 10,
ordered: false
)
|> Enum.to_list()Timeout Handling
task = Task.Supervisor.async_nolink(MySup, fn -> slow_work() end)
case Task.yield(task, 5_000) || Task.shutdown(task) do
{:ok, result} -> {:ok, result}
nil -> {:error, :timeout}
{:exit, reason} -> {:error, reason}
endBackpressure
GenStage / Broadway for Backpressure
# Producer-consumer with demand
defmodule MyConsumer do
use GenStage
def handle_events(events, _from, state) do
process(events)
{:noreply, [], state} # Demand more when ready
end
endManual Backpressure
# Limit concurrent operations
defmodule RateLimiter do
use GenServer
def init(_) do
{:ok, %{active: 0, max: 10, queue: :queue.new()}}
end
def handle_call(:acquire, from, %{active: n, max: max} = state) when n < max do
{:reply, :ok, %{state | active: n + 1}}
end
def handle_call(:acquire, from, state) do
{:noreply, %{state | queue: :queue.in(from, state.queue)}}
end
def handle_cast(:release, %{queue: queue, active: n} = state) do
case :queue.out(queue) do
{{:value, from}, queue} ->
GenServer.reply(from, :ok)
{:noreply, %{state | queue: queue}}
{:empty, _} ->
{:noreply, %{state | active: n - 1}}
end
end
endProcess Spawning
Don't Spawn Unbounded
# BAD - spawns process per request
def handle_request(req) do
spawn(fn -> process(req) end) # Unbounded!
end
# GOOD - use pool
def handle_request(req) do
:poolboy.transaction(:worker_pool, fn pid ->
Worker.process(pid, req)
end)
endDynamicSupervisor for Bounded Children
defmodule MyDynamicSup do
use DynamicSupervisor
def start_link(_) do
DynamicSupervisor.start_link(__MODULE__, [],
name: __MODULE__,
max_children: 100 # Bounded!
)
end
endReview Questions
1. Are dynamic tasks under a Task.Supervisor? 2. Is there backpressure for high-volume producers? 3. Is process spawning bounded? 4. Are timeouts configured for async operations?
ETS Patterns
When to Use ETS
| Use Case | ETS? |
|---|---|
| Read-heavy cache | Yes |
| Write-heavy with consistency | No (use GenServer) |
| Shared state across processes | Yes |
| Small, single-process state | No (use GenServer) |
Table Types
# :set - one value per key (default)
:ets.new(:cache, [:set])
# :bag - multiple values per key
:ets.new(:events, [:bag])
# :ordered_set - sorted by key
:ets.new(:timeline, [:ordered_set])Concurrency Options
# Read-heavy workload
:ets.new(:cache, [:set, :public, :named_table,
read_concurrency: true
])
# Write-heavy workload
:ets.new(:counters, [:set, :public, :named_table,
write_concurrency: true
])
# Both
:ets.new(:mixed, [:set, :public, :named_table,
read_concurrency: true,
write_concurrency: true
])Common Patterns
Cache with TTL
defmodule TTLCache do
def put(key, value, ttl_ms) do
expires_at = System.monotonic_time(:millisecond) + ttl_ms
:ets.insert(:cache, {key, value, expires_at})
end
def get(key) do
case :ets.lookup(:cache, key) do
[{^key, value, expires_at}] ->
if System.monotonic_time(:millisecond) < expires_at do
{:ok, value}
else
:ets.delete(:cache, key)
:expired
end
[] ->
:not_found
end
end
endCounter
# Atomic counter updates
:ets.update_counter(:stats, :requests, 1, {:requests, 0})Match Specifications
# Find all users with role :admin
:ets.select(:users, [
{{:"$1", %{role: :admin}}, [], [:"$1"]}
])
# Using match
:ets.match(:users, {:"$1", %{role: :admin, name: :"$2"}})
# Returns [[id1, name1], [id2, name2], ...]Access Control
# :public - any process can read/write
# :protected - owner writes, any reads (default)
# :private - only owner
:ets.new(:shared, [:public]) # Multi-process cache
:ets.new(:config, [:protected]) # Owner updates, all read
:ets.new(:internal, [:private]) # Single process onlyOwnership and Lifecycle
# ETS table dies with owner process
# Use a dedicated process to own long-lived tables
defmodule TableOwner do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
def init(_) do
table = :ets.new(:my_table, [:public, :named_table])
{:ok, table}
end
endReview Questions
1. Is ETS appropriate for this use case (read vs write ratio)? 2. Are concurrency options set correctly? 3. Is table ownership properly managed? 4. Are access controls appropriate?
GenServer Bottlenecks
Single Process Bottleneck
The Problem
# BAD - all requests through one process
defmodule Cache do
use GenServer
def get(key), do: GenServer.call(__MODULE__, {:get, key})
def put(key, val), do: GenServer.call(__MODULE__, {:put, key, val})
endEvery request queues in the GenServer's mailbox. Under load:
- Mailbox grows unbounded
- Latency increases linearly
- Memory pressure from queued messages
Solutions
1. Use ETS for read-heavy workloads:
defmodule Cache do
def init do
:ets.new(:cache, [:set, :public, :named_table, read_concurrency: true])
end
def get(key), do: :ets.lookup(:cache, key)
def put(key, val), do: :ets.insert(:cache, {key, val})
end2. Partition by key:
defmodule PartitionedCache do
@partitions 16
def get(key) do
partition = :erlang.phash2(key, @partitions)
GenServer.call(:"cache_#{partition}", {:get, key})
end
end3. Use Registry for dynamic workers:
defmodule WorkerPool do
def get_worker(key) do
case Registry.lookup(MyRegistry, key) do
[{pid, _}] -> pid
[] -> start_worker(key)
end
end
endBlocking Operations
The Problem
# BAD - blocks entire GenServer
def handle_call(:fetch_external, _from, state) do
result = HTTPClient.get!(url) # 500ms+ network call
{:reply, result, state}
endAll other messages wait during the HTTP call.
Solutions
1. Use Task.Supervisor for async work:
def handle_call(:fetch_external, from, state) do
task = Task.Supervisor.async_nolink(MyApp.TaskSupervisor, fn ->
HTTPClient.get!(url)
end)
{:noreply, Map.put(state, :pending, {from, task.ref})}
end
def handle_info({ref, result}, %{pending: {from, ref}} = state) do
Process.demonitor(ref, [:flush])
GenServer.reply(from, result)
{:noreply, Map.delete(state, :pending)}
end
def handle_info({:DOWN, ref, :process, _pid, reason}, %{pending: {from, ref}} = state) do
GenServer.reply(from, {:error, reason})
{:noreply, Map.delete(state, :pending)}
end2. Use handle_continue for expensive init:
def init(args) do
{:ok, %{}, {:continue, :load_data}}
end
def handle_continue(:load_data, state) do
data = expensive_load()
{:noreply, %{state | data: data}}
endTimeouts
Configure Appropriately
# Client-side timeout (use catch, not rescue - timeouts are exit signals)
def fetch(pid) do
try do
GenServer.call(pid, :fetch, 10_000) # 10 second timeout
catch
:exit, {:timeout, _} -> {:error, :timeout}
end
end
# Server-side timeout for idle
def handle_info(:timeout, state) do
{:stop, :normal, state}
end
def handle_call(:work, _from, state) do
{:reply, :ok, state, 30_000} # 30s idle timeout
endReview Questions
1. Is this GenServer a potential bottleneck under load? 2. Are there blocking I/O operations in callbacks? 3. Would ETS be more appropriate for this use case? 4. Are timeouts configured appropriately?
Memory Patterns
Binary Handling
Large Binaries Are Reference Counted
Binaries > 64 bytes are stored on shared heap. Copying between processes is cheap (reference copy).
# Efficient - only reference copied
send(pid, large_binary)
# But beware of sub-binaries holding reference to large binary
<<header::binary-size(100), _rest::binary>> = large_binary
# header still references entire large_binary!Force Copy When Needed
# Release reference to large binary
header = :binary.copy(<<header::binary-size(100), _::binary>> = large_binary)Process Heap
Large State = Large GC
Each process has its own heap. Large state means:
- Longer GC pauses
- More memory per process
# BAD - accumulating large state
def handle_cast({:add, item}, state) do
{:noreply, [item | state.items]} # Grows forever!
end
# GOOD - bounded state
def handle_cast({:add, item}, state) do
items = Enum.take([item | state.items], @max_items)
{:noreply, %{state | items: items}}
endUse ETS for Large Shared State
# BAD - large map in GenServer
defmodule BigCache do
use GenServer
def init(_), do: {:ok, %{}} # Millions of entries here
end
# GOOD - ETS for large state
defmodule BigCache do
def init do
:ets.new(:cache, [:set, :public, :named_table])
end
endMessage Passing
Avoid Large Message Copies
# BAD - copies entire list to each process
Enum.each(workers, fn pid ->
send(pid, {:process, large_list})
end)
# GOOD - send reference or key
Enum.each(workers, fn pid ->
send(pid, {:process, :ets.whereis(:data), key})
end)Streams for Large Data
Use Streams to Avoid Loading All in Memory
# BAD - loads entire file
File.read!("large.csv")
|> String.split("\n")
|> Enum.map(&parse_line/1)
# GOOD - streams line by line
File.stream!("large.csv")
|> Stream.map(&parse_line/1)
|> Enum.to_list() # Or process incrementallyDatabase Streams
# BAD - loads all records
Repo.all(User)
|> Enum.map(&process/1)
# GOOD - streams from database
User
|> Repo.stream()
|> Stream.map(&process/1)
|> Stream.run()Detecting Memory Issues
# Process memory
Process.info(self(), :memory)
# System memory
:erlang.memory()
# Binary memory specifically
:erlang.memory(:binary)Review Questions
1. Are large binaries being unnecessarily copied? 2. Is process state bounded or growing unbounded? 3. Are streams used for large data processing? 4. Is shared state in ETS rather than process heap?