From the blog

Rate Limits vs Quotas in Phoenix: Enforcing Plan Limits with ETS

By Liam Killingback ·

Rate Limits vs Quotas in Phoenix: Enforcing Plan Limits with ETS

Two limits guard every API, and teams routinely ship one when they meant the other. A rate limit is short and technical: 600 requests a minute, protecting your servers from a runaway script. A quota is long and commercial: 100,000 events a month, protecting the price of your Pro plan. They look identical in code (a counter and a cap) and they behave nothing alike in production.

Get them confused and you produce two classic bugs. Returning 429 Too Many Requests when a customer exhausts their monthly plan makes every well-behaved client retry forever, because 429 means “come back shortly” and no amount of waiting will refill a monthly allowance. Enforcing a monthly quota with a per-node in-memory counter means the limit silently triples the day you scale to three machines, and you give away the difference.

This guide builds both in Phoenix with ETS, keeps them correct under concurrency, and shows where each one belongs.

The difference, in one table

Rate limit Quota
Window Seconds or minutes The billing period
Protects Your infrastructure Your pricing
Set by Engineering The plan the customer bought
Response 429 with retry-after 402 or 403 with an upgrade path
How the caller fixes it Slows down Upgrades, or waits for the reset
Correct scope Per node is fine Must be cluster-wide
Resets Automatically, every window On the billing anniversary
Overshoot Harmless Money

The last two rows are why a quota cannot be a rate limit with a longer window. A rate-limit counter that drifts by a few requests costs you nothing. A quota counter that drifts by a few thousand events is either revenue you did not bill or service you gave away.

Part 1: The rate limit, in ETS

A rate limit is a hot-path check. It runs before you do any work, on every request, so it cannot touch Postgres. ETS with write_concurrency and atomic update_counter gives you a check in the low microseconds.

The simplest correct approach is a fixed window: bucket the current time, and count hits per bucket.

defmodule MyApp.RateLimit do
  @moduledoc """
  Fixed-window rate limiting on an ETS table.

  Records are {{key, bucket}, count, expires_at_ms}.
  """
  use GenServer

  @table :rate_limit
  @sweep_every :timer.minutes(1)

  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  @impl true
  def init(_opts) do
    :ets.new(@table, [
      :named_table,
      :public,
      :set,
      read_concurrency: true,
      write_concurrency: true,
      decentralized_counters: true
    ])

    Process.send_after(self(), :sweep, @sweep_every)
    {:ok, %{}}
  end

  @doc """
  Counts one hit against `key`. Returns {:ok, remaining} or
  {:error, :rate_limited, retry_after_seconds}.
  """
  def hit(key, limit, window_ms) do
    now = System.system_time(:millisecond)
    bucket = div(now, window_ms)
    resets_at = (bucket + 1) * window_ms
    default = {{key, bucket}, 0, resets_at}

    count = :ets.update_counter(@table, {key, bucket}, {2, 1}, default)

    if count > limit do
      {:error, :rate_limited, ceil((resets_at - now) / 1000)}
    else
      {:ok, limit - count}
    end
  end

  @impl true
  def handle_info(:sweep, state) do
    now = System.system_time(:millisecond)
    :ets.select_delete(@table, [{{:_, :_, :"$1"}, [{:<, :"$1", now}], [true]}])
    Process.send_after(self(), :sweep, @sweep_every)
    {:noreply, state}
  end
end

Three details are doing real work here.

:ets.update_counter/4 with a default object is atomic: the read, the increment and the insert-if-missing happen in one operation inside ETS, so two requests arriving on two schedulers at the same instant cannot both read 599 and both write 600. decentralized_counters: true keeps that counter from becoming a contention point on a machine with many cores. And the expiry timestamp stored in the record means the sweep is one match spec over the whole table, whatever mix of window sizes you use.

The honest limitation of a fixed window is the boundary burst: a caller can send limit requests in the last moment of one window and limit again in the first moment of the next, so a 600 a minute limit tolerates 1,200 in a two second span. For most SaaS APIs that is fine. If it is not, keep two adjacent buckets and weight the older one by how far into the current window you are (a sliding window), or switch to a token bucket. Hammer implements these off the shelf and is a reasonable dependency if rate limiting is all you need.

Part 2: The plug, and the headers clients actually read

defmodule MyAppWeb.Plugs.RateLimit do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, opts) do
    limit = Keyword.fetch!(opts, :limit)
    window = Keyword.get(opts, :window_ms, :timer.minutes(1))

    case MyApp.RateLimit.hit(bucket_key(conn), limit, window) do
      {:ok, remaining} ->
        put_limit_headers(conn, limit, remaining)

      {:error, :rate_limited, retry_after} ->
        conn
        |> put_limit_headers(limit, 0)
        |> put_resp_header("retry-after", Integer.to_string(retry_after))
        |> put_status(:too_many_requests)
        |> Phoenix.Controller.json(%{
          error: "rate_limited",
          message: "Too many requests. Retry in #{retry_after}s.",
          retry_after: retry_after
        })
        |> halt()
    end
  end

  defp put_limit_headers(conn, limit, remaining) do
    conn
    |> put_resp_header("ratelimit-limit", Integer.to_string(limit))
    |> put_resp_header("ratelimit-remaining", Integer.to_string(remaining))
  end

  defp bucket_key(%{assigns: %{api_key: %{id: id}}}), do: {:api_key, id}
  defp bucket_key(%{assigns: %{current_user: %{id: id}}}), do: {:user, id}
  defp bucket_key(conn), do: {:ip, :inet.ntoa(conn.remote_ip)}
end

Key on the API key or the user first and fall back to the IP only for anonymous traffic. Keying everything on IP punishes an entire office behind one NAT and does nothing against a distributed caller.

Put it in the API pipeline after authentication, so you know who the caller is before you count them:

pipeline :api do
  plug :accepts, ["json"]
  plug MyAppWeb.Plugs.ApiAuth
  plug MyAppWeb.Plugs.RateLimit, limit: 600, window_ms: :timer.minutes(1)
end

If the limit itself depends on the plan, read it from the caller’s subscription rather than hard-coding it:

plug MyAppWeb.Plugs.RateLimit, limit: {:plan, :requests_per_minute}

Part 3: The quota, which is a different problem

Now the commercial limit. Three things change.

It is checked per billable unit, not per request. One request might consume 1 event, or 40 (a batch endpoint), or 12,000 tokens. Count the unit you sell.

Overshoot costs money, so the check and the increment must be one operation. This is the code every team writes wrong the first time:

# WRONG: two callers can both read 99_999 and both pass.
used = Usage.current(account_id)
if used + amount <= cap do
  Usage.increment(account_id, amount)
  :ok
else
  {:error, :quota_exceeded}
end

Under any real concurrency, that gap between the read and the write is where your free tier leaks. Increment first, then roll back if the cap was blown:

defmodule MyApp.Quota do
  @table :quota

  @doc """
  Consumes `amount` units against a period counter.
  Returns {:ok, remaining} or {:error, :quota_exceeded, used}.
  """
  def consume(account_id, feature, amount, cap) do
    key = {account_id, feature, period()}
    total = :ets.update_counter(@table, key, {2, amount}, {key, 0})

    if total <= cap do
      {:ok, cap - total}
    else
      # Give the units back so a rejected call does not consume the plan.
      :ets.update_counter(@table, key, {2, -amount})
      {:error, :quota_exceeded, total - amount}
    end
  end

  defp period do
    %{year: y, month: m} = Date.utc_today()
    {y, m}
  end
end

The counter can exceed the cap for the few microseconds between the two calls, but it never stays there, and no two callers can both be told yes when only one unit remained. That is the property you need, and it costs one extra atomic operation on the rejection path only.

Deriving the period key from the date means there is no reset job to forget: when the month rolls over, the key changes and the new counter starts at zero. If you bill on the subscription anniversary rather than the calendar month, derive the period from current_period_start on the subscription instead. That, and how the counters get flushed to Postgres so a restart does not zero everybody’s usage, is covered in metering API usage in Phoenix in real time with ETS.

The answer is not 429. A caller who has used their monthly allowance should get a status that tells them the truth:

case MyApp.Quota.consume(account.id, :events, 1, plan.events) do
  {:ok, remaining} ->
    conn |> put_resp_header("quota-remaining", Integer.to_string(remaining)) |> continue()

  {:error, :quota_exceeded, used} ->
    conn
    |> put_status(:payment_required)
    |> json(%{
      error: "quota_exceeded",
      message: "You have used #{used} of #{plan.events} events this period.",
      upgrade_url: url(~p"/pricing"),
      resets_at: MyApp.Quota.period_end(account)
    })
    |> halt()
end

402 Payment Required is the honest code when money is the fix. 403 is acceptable if your clients choke on 402. Whichever you pick, include the reset time and a link to the upgrade page, because that response is a sales conversation, not an error.

Part 4: Declare both on the plan, not in the router

Once a limit is commercial, it belongs next to the price. Keep one definition and read it in the plug, the LiveView and the pricing page:

defmodule MyApp.Plans do
  @plans %{
    free: %{
      rate: %{limit: 60, window_ms: :timer.minutes(1)},
      quota: %{events: 10_000},
      on_exceeded: :block
    },
    pro: %{
      rate: %{limit: 600, window_ms: :timer.minutes(1)},
      quota: %{events: 1_000_000},
      on_exceeded: :bill_overage
    },
    scale: %{
      rate: %{limit: 3_000, window_ms: :timer.minutes(1)},
      quota: %{events: 20_000_000},
      on_exceeded: :bill_overage
    }
  }

  def fetch!(name), do: Map.fetch!(@plans, name)
  def names, do: Map.keys(@plans)
end

on_exceeded is the interesting field. Blocking is right for a free tier, where the wall is the product. On a paid plan, blocking an integration mid-month is a support ticket and a churn risk, so bill the overage instead: let the call through, record the excess, and report it to Stripe as metered usage at the end of the period. The mechanics of feature-level entitlements, including which features a plan includes at all, are in plan-based feature gating and entitlements in Phoenix.

Part 5: Tell people before they hit the wall

A quota that surprises a customer is a bad quota. Two cheap warnings cover most of it.

Broadcast crossings over PubSub as the counter passes them, and let a LiveView pick them up:

defp maybe_warn(account_id, used, cap) do
  before = used - 1

  for threshold <- [0.8, 1.0],
      before < cap * threshold and used >= cap * threshold do
    Phoenix.PubSub.broadcast(
      MyApp.PubSub,
      "usage:#{account_id}",
      {:usage_threshold, threshold, used, cap}
    )
  end
end

Then a banner in the app layout, and one email at 80% with the upgrade link. The email is the one that actually converts, because the person who reads the banner is often not the person who owns the bill.

Part 6: Per node is fine for one, fatal for the other

Rate limits can live entirely in each node’s ETS table. Three nodes behind a load balancer each enforcing 200 a minute is a 600 a minute limit, near enough, and if a node restarts the worst case is one caller getting a slightly generous minute. Divide the intended limit by the node count and move on.

Quotas cannot work that way, because the total is the thing you sell. Three nodes each holding a partial count means nobody knows the real number, and the wall never lands where the plan says it does. Options, roughly in order of effort:

  1. One owner process per account, registered globally, that holds the counter. Correct and simple, but that process is a single point of contention and it needs a handoff plan when its node goes down.
  2. Postgres as the source of truth, with UPDATE ... SET used = used + $1 WHERE used + $1 <= cap RETURNING used. Atomic, durable and boring, at the cost of a database round trip on every billable call. Worth it when calls are expensive anyway, such as an AI endpoint.
  3. ETS in front, Postgres behind: every node counts locally and flushes deltas on an interval, with the enforcement check reading the shared total. This is what most metering systems do, and it trades a small, bounded overshoot near the cap for a hot path that never blocks.

Option 3 is what Aurora Meter implements: atomic ETS counters on the request path, periodic flushes to Postgres, and cluster-aware totals so the cap is enforced against what the whole fleet has counted rather than what one node remembers. The free core is MIT licensed and on GitHub.

Part 7: Testing both

Rate limits are easy to test if hit/3 takes the window as an argument, because you can use a tiny window instead of sleeping for a minute:

test "blocks the request over the limit and recovers next window" do
  key = {:test, System.unique_integer()}

  assert {:ok, 1} = MyApp.RateLimit.hit(key, 2, 100)
  assert {:ok, 0} = MyApp.RateLimit.hit(key, 2, 100)
  assert {:error, :rate_limited, _} = MyApp.RateLimit.hit(key, 2, 100)

  Process.sleep(120)
  assert {:ok, 1} = MyApp.RateLimit.hit(key, 2, 100)
end

For the quota, test the property that matters, which is that concurrency cannot oversell it:

test "100 concurrent consumers cannot exceed a cap of 50" do
  account = insert(:account)

  results =
    1..100
    |> Task.async_stream(fn _ -> MyApp.Quota.consume(account.id, :events, 1, 50) end,
      max_concurrency: 20
    )
    |> Enum.map(fn {:ok, result} -> result end)

  assert Enum.count(results, &match?({:ok, _}, &1)) == 50
  assert MyApp.Quota.used(account.id, :events) == 50
end

That second test fails immediately against the read-then-write version from Part 3, which is the best argument for writing it.

What to reach for

  • Rate limiting only: Hammer, or the 60 lines above. Both are fine.
  • Quotas and entitlements without billing: the plan map plus the atomic consume, kept in your own code.
  • Usage you invoice for: you now need durable counters, a billing period that matches the subscription, overage reporting to Stripe and a usage view the customer trusts. That is where writing it yourself stops being a weekend and starts being a system.

Aurora Meter Pro is the paid layer over the free core: cluster-wide enforcement, Stripe Billing Meters reporting, plan sync and the LiveView usage components. It is licensed per app from $99 a month with a 14-day free trial, one per customer, and the pricing page has the details. Building the metering into a Phoenix SaaS from scratch instead? phx_saas gives you the auth, billing and admin scaffolding to hang it on.

Summary

Rate limits and quotas are different tools. The rate limit is per node, per minute, enforced in ETS with an atomic update_counter, and answered with 429 and a retry-after header. The quota is cluster-wide, per billing period, checked against the plan, and answered with 402 and an upgrade link. Both need the increment and the check to be one atomic operation, both should warn the customer before they land, and only one of them is safe to keep in a single node’s memory.

Ship the rate limit first, because it protects you from accidents this week. Ship the quota when the plan you are selling depends on it, and make sure the counter behind it is one your whole cluster agrees on.