We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
From the blog
Phoenix Usage Metering in Real Time with ETS
By Liam Killingback ·
Phoenix Usage Metering in Real Time with ETS
Every SaaS that charges for what customers actually do, API calls, AI tokens, exported reports, background jobs, has to answer one question on every single request: how much has this customer used so far? Get it right and you can enforce quotas, power a live usage dashboard, and bill accurately at the end of the month. Get it wrong and you either undercount (leaking revenue) or hammer your database with a write on every request until it falls over.
This is the problem Phoenix usage metering solves, and Elixir happens to be almost unfairly good at it. The BEAM ships with ETS, an in-memory store built for exactly this kind of high-frequency, concurrent counting. In this guide we build a real-time metering layer from scratch: count usage on an ETS hot path in microseconds, enforce plan quotas inline, and flush aggregated counts to Postgres on a schedule so nothing is lost. It doubles as a look under the hood of what Aurora Meter does for you in production.
This post is part of our work on usage-based billing and metering for Phoenix. If your usage comes from AI features, it pairs naturally with streaming OpenAI responses in Phoenix LiveView, where every token you stream is a metered event.
Why not just count in Postgres?
The obvious first move is an UPDATE usages SET count = count + 1 on every request. It works until it doesn’t. Each metered event becomes a synchronous write, a row lock, and a round trip to the database that sits directly in your request path. At a few requests per second you will never notice. At a few thousand, that single hot row becomes a contention point, and your p99 latency starts tracking your database’s write queue instead of your actual code.
The insight is that metering does not need to be transactional on the hot path. If a customer makes 10,000 API calls in a minute, you do not need 10,000 durable writes. You need a fast, correct counter now, and a durable record eventually. That “fast now, durable later” split is exactly what ETS plus a periodic flush gives you.
Why ETS fits
ETS (Erlang Term Storage) is an in-memory key/value store built into the runtime. A few properties make it ideal for metering:
- It is fast. Reads and writes are constant time and measured in microseconds, with no serialization or network hop.
-
It is concurrent. With
write_concurrencyenabled, many processes can update different keys in parallel without blocking each other. -
It has atomic counters.
:ets.update_counter/4increments a value atomically, so concurrent requests for the same customer never lose a count to a race.
The honest tradeoff: ETS lives in memory on one node. If the node restarts you lose whatever has not been flushed, and in a multi-node cluster each node has its own table. We handle both below. Neither is a dealbreaker, they are design constraints you account for.
The design
Three moving parts, each with one job:
- A counter table in ETS that increments on every metered event.
- A plug that meters each API request and enforces the plan’s quota before your controller runs.
- A flusher that periodically drains the ETS counts into Postgres so usage is durable and queryable for billing.
Reads for a live dashboard come straight off ETS, so the number a customer sees updates instantly.
Step 1: the counter table
ETS tables are owned by a process and die with it, so we create the table inside a small GenServer that lives for the life of the application. Making it :public with write concurrency lets request processes write to it directly, without routing every increment through the GenServer’s mailbox (which would reintroduce the bottleneck we are trying to avoid).
defmodule MyApp.Metering.Counters do
@moduledoc "Owns the ETS table that holds live usage counts."
use GenServer
@table :usage_counters
def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
@doc "Add `n` to the running count for a {customer_id, metric} pair."
def increment(customer_id, metric, n \\ 1) do
:ets.update_counter(@table, {customer_id, metric}, n, {{customer_id, metric}, 0})
end
@doc "Read the current count for a {customer_id, metric} pair."
def current(customer_id, metric) do
case :ets.lookup(@table, {customer_id, metric}) do
[{_key, count}] -> count
[] -> 0
end
end
@doc "Atomically read-and-reset every counter. Used by the flusher."
def drain do
rows = :ets.tab2list(@table)
:ets.delete_all_objects(@table)
rows
end
@impl true
def init(:ok) do
:ets.new(@table, [
:named_table,
:public,
:set,
read_concurrency: true,
write_concurrency: true
])
{:ok, %{}}
end
end
The key trick is the fourth argument to :ets.update_counter/4. That {{customer_id, metric}, 0} is a default object inserted atomically when the key does not exist yet, so the very first request for a customer works without a separate “create the row” step. The whole increment is one lock-free operation.
Add it to your supervision tree so the table exists before any request lands:
# lib/my_app/application.ex
children = [
MyApp.Repo,
MyApp.Metering.Counters,
# ... flusher and endpoint below
MyAppWeb.Endpoint
]
Step 2: meter and gate requests with a plug
A Plug is the natural place to meter an API, because it sees every request before your controller does and can halt the ones that are over quota. This plug looks up the customer’s plan limit, checks the live ETS count, and either rejects the request with 429 Too Many Requests or records the call and lets it through.
defmodule MyAppWeb.Plugs.MeterUsage do
import Plug.Conn
alias MyApp.Metering.Counters
@metric :api_call
def init(opts), do: opts
def call(conn, _opts) do
customer = conn.assigns.current_customer
used = Counters.current(customer.id, @metric)
if used >= customer.plan_limit do
conn
|> put_resp_content_type("application/json")
|> send_resp(429, Jason.encode!(%{error: "usage limit reached", limit: customer.plan_limit}))
|> halt()
else
Counters.increment(customer.id, @metric)
assign(conn, :usage_after, used + 1)
end
end
end
Wire it into the pipeline for your metered routes, after whatever plug assigns current_customer:
# lib/my_app_web/router.ex
pipeline :metered_api do
plug :accepts, ["json"]
plug MyAppWeb.Plugs.AuthenticateCustomer
plug MyAppWeb.Plugs.MeterUsage
end
scope "/api", MyAppWeb do
pipe_through :metered_api
get "/reports", ReportController, :index
end
There is a subtle correctness point worth naming. Between the current/2 read and the increment/2 write, two requests could both see used = 99 against a limit of 100 and both proceed, letting one call slip over. For a soft quota that drives billing, being off by one on a burst is fine, you still bill for the call. If you need a hard cap, do the check and increment in a single atomic step and compare the return value:
def call(conn, _opts) do
customer = conn.assigns.current_customer
used_after = Counters.increment(customer.id, :api_call)
if used_after > customer.plan_limit do
Counters.increment(customer.id, :api_call, -1) # give the slot back
conn |> send_resp(429, "usage limit reached") |> halt()
else
conn
end
end
Because update_counter returns the new value atomically, no two requests can ever get the same used_after, so the cap holds exactly even under a burst.
Step 3: flush to Postgres on a schedule
ETS gives us speed but not durability. A second GenServer drains the counters every minute and writes the aggregated deltas to Postgres, where they become queryable history for invoices, reporting, and reconciliation.
defmodule MyApp.Metering.Flusher do
use GenServer
alias MyApp.Metering.Counters
alias MyApp.Repo
@interval :timer.minutes(1)
def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
@impl true
def init(:ok) do
{:ok, schedule()}
end
@impl true
def handle_info(:flush, state) do
flush()
{:noreply, schedule(state)}
end
defp schedule(state \\ %{}) do
Process.send_after(self(), :flush, @interval)
state
end
defp flush do
now = DateTime.utc_now() |> DateTime.truncate(:second)
rows =
for {{customer_id, metric}, count} <- Counters.drain(), count > 0 do
%{
customer_id: customer_id,
metric: to_string(metric),
count: count,
period_start: now,
inserted_at: now,
updated_at: now
}
end
if rows != [] do
Repo.insert_all("usage_events", rows)
end
end
end
Two details make this safe. First, drain/0 reads and clears the table in one shot, so events counted after the drain belong to the next window and are never double counted. Second, we write with insert_all/3 in a single batch, so one flush is one round trip to Postgres regardless of how many customers were active. A thousand busy customers cost you one insert per minute instead of thousands of writes per second.
If you would rather run the flush as a durable, observable background job with retries, reach for Oban and schedule the same flush/0 logic on a cron trigger. That is the approach the production libraries take, and it gives you a paper trail if a flush ever fails.
Reading usage for a live dashboard
Because the current count is always in ETS, a LiveView usage meter reads it directly, no database query on render. Combine the live ETS value with the last flushed total from Postgres to show an accurate running number:
def mount(_params, _session, socket) do
if connected?(socket), do: :timer.send_interval(2_000, :tick)
{:ok, assign_usage(socket)}
end
def handle_info(:tick, socket), do: {:noreply, assign_usage(socket)}
defp assign_usage(socket) do
customer = socket.assigns.customer
live = MyApp.Metering.Counters.current(customer.id, :api_call)
flushed = MyApp.Metering.persisted_total(customer.id, :api_call)
assign(socket, :usage, live + flushed)
end
The customer watches their usage climb in near real time, which is exactly the transparency usage-based pricing needs to feel fair.
What about multiple nodes?
ETS is per-node, so in a cluster each node counts the traffic it serves. Two clean ways to handle it:
-
Flush per node, sum in Postgres. Each node runs its own flusher and inserts its deltas. Your billing query does
SELECT sum(count) ... GROUP BY customer_id, which naturally aggregates across nodes. This is the simplest option and it is usually enough. -
Aggregate in memory with a library like a CRDT counter or a dedicated metering process per customer via
:pgprocess groups, when you need a globally accurate live number for hard, cluster-wide quotas.
Start with the first. Reach for the second only when a per-node approximation on the live number is genuinely not good enough, which is rarer than it sounds.
Skip the plumbing
What we built here is the honest core of a metering system, and it is a few hundred lines to get counting, gating, flushing, multi-node aggregation, and Stripe reporting all correct and tested. That is exactly what Aurora Meter packages up: it meters usage on an ETS hot path, enforces plan entitlements, and, in Aurora Meter Pro, reports the aggregated usage to Stripe Billing Meters so the numbers you count turn into invoices without a webhook maze. The core is free and MIT licensed, so you can read exactly how the pieces above fit together in production.
If you are assembling a whole SaaS and not just the metering piece, the PhxTemplates catalog and Builder Pass give you auth, billing, email, and admin already wired, so metering slots into an app that is ready to charge from day one.
Summary
Real-time Phoenix usage metering comes down to a simple split: count fast in memory, persist durably on a schedule. ETS handles the hot path with atomic counters that never lose a race, a plug enforces plan quotas inline before your controller runs, and a periodic flusher turns millions of in-memory increments into a handful of batched Postgres writes. The BEAM was built for exactly this shape of work, high concurrency, low latency, in-process state, which is why metering on Phoenix ends up simpler and faster than the same feature on most other stacks.
Build it yourself when you want to understand every counter, or lean on Aurora Meter when you would rather ship the feature this week. Either way, you now have a metering layer that scales with your traffic instead of buckling under it.