R6.01 · Flow Control & Scale — module hub
Rate limiting — a budget per window.
Rate-limiting caps work to a budget per unit of time: count what is admitted in the current window, refuse or defer once the count reaches the limit, and reset when the window rolls. It is the simplest form of flow control — a number, a window, and a comparison — and the store does the counting, so the budget is the same for every worker at once.
Grounding: echo/apps/echo_mq · EchoMQ.Metrics.get_rate_limit_ttl/3 (reads emq:{q}:limiter spent down to max from emq:{q}:meta) · EchoMQ.Meter.rate_limit_hit/2 (emits [:emq, :rate_limit, :hit]) · EchoMQ.Keyspace (the limiter/meta keys, the cluster slot). Consumer: codemojex.
How it works
the window counterA rate limit needs three things: a counter, a window, and a limit. The window counter is the smallest arrangement that supplies all three with two commands. On each admitted request, increment the counter for the current window:
INCR emq:{orders}:limiter # → the running count for this window
INCR returns the new value after the increment, so the count comes back in the same
round trip that records it. If the returned count is 1, this request opened the window —
set the window's lifetime so it expires when the window rolls:
PEXPIRE emq:{orders}:limiter 1000 # only on the first increment of the window
From then on, every request compares the returned count against the configured limit. While the count is at or below the limit, the request is admitted. Once the count has reached the limit, the request is refused or deferred, and the back-off is exactly the time left on the key — the window does the resetting for you:
PTTL emq:{orders}:limiter # → ms until the budget resets (the back-off)
The window is the key's TTL. There is no separate timer, no scheduled reset, no clean-up sweep:
when the key expires, the count is gone and the next INCR recreates it at 1. The
counter is self-resetting because expiry is the reset — the same property the rest of this course leans
on for sessions, locks, and idempotency keys.
A counter, a window equal to the key's TTL, and a comparison against a limit. Increment on admit, expire on the first hit, refuse at the limit, and read the remaining time as the back-off.
Redis / Valkey commands used
valkey · the counter verbsThe window counter is built from six commands, every one a documented Valkey operation. None is EchoMQ-specific; the pattern is plain Valkey, applied.
INCR— atomically increment the counter and return the new value. One round trip records the admission and reports the count.PEXPIRE(orEXPIREfor second precision) — set the counter's lifetime to the window length, applied on the first increment so the window starts when its first request arrives.SET … PX— an alternative first-write that sets the value and the expiry together; used where the count is written rather than incremented.PTTL(orTTL) — read the time remaining on the key. When over budget, this is the precise back-off a caller waits before retrying.GET— read the current count without changing it; the read side of a limiter check.HGET— read the configured limit from a side hash (HGET emq:{orders}:meta max) so the cap is data, not a hard-coded constant.
Notes on ValkeyINCR on a missing key creates it at
0 and increments to 1 atomically, so two concurrent first-requesters cannot both
be told they opened the window. INCR and EXPIRE are two commands, though: between them
a process could die and leave a counter with no expiry that never resets. The robust form binds them — a
SET key 1 PX <window> NX first-write, or a short Lua script that increments and sets the
expiry under one execution — valkey.io/commands/incr.
The four algorithms
the limiter familyThe window counter is the simplest member of a family. Four algorithms trade simplicity against smoothness, and the right one depends on whether bursts are acceptable and how much state you will keep:
- Fixed window — one counter per fixed clock window (the shape in §1). Cheap and exact within a window, but bursty at the boundary: a caller can spend the full budget at the end of one window and again at the start of the next, admitting up to twice the limit across the seam.
- Sliding window — weight the previous window's count by the fraction still in view, or keep a sorted set of request timestamps trimmed to the last window. Smooths the boundary burst at the cost of more state.
- Token bucket — tokens accrue at a fixed rate up to a bucket size; each admitted request spends one. Permits bursts up to the bucket and sustains the refill rate — the shape for when short bursts are acceptable.
- Leaky bucket — a queue drained at a fixed rate, so output is constant regardless of how arrivals clump. Smooths the output rather than the input.
The fixed window is what EchoMQ's limiter implements, and the dives take each algorithm apart in turn: windows and the boundary burst, the two buckets and what they smooth, and why the counter must live in the store rather than in any worker.
Fixed & sliding windows
The fixed-window counter (INCR + PEXPIRE, refuse at max),
its boundary burst, and the sliding-window refinements that smooth it.
Token & leaky buckets
A refilling allowance that permits bursts against a fixed-rate drain that smooths output; catalog algorithms over a small Valkey hash, not shipped bus surfaces.
Global, not local
Why the counter lives in Valkey: a per-worker limiter with N workers admits N× the rate;
the {q} hash tag pins one shared counter to one cluster slot.
Four algorithms, one trade: simplicity against smoothness. The fixed window is the cheapest and the shape EchoMQ ships; the buckets buy burst-awareness and a smooth output for more state.
Applied in EchoMQ
echo/apps/echo_mqEchoMQ exposes the read side of a fixed-window limiter as a shipped surface.
EchoMQ.Metrics.get_rate_limit_ttl/3 reads two keys — emq:{q}:limiter and
emq:{q}:meta — and answers the remaining window in milliseconds, 0 when the
queue is not over budget. Its inline Lua is the fixed-window read, exactly the §1 shape:
local max = tonumber(ARGV[1]) if max == 0 then max = tonumber(redis.call('HGET', KEYS[2], 'max') or '0') end if max > 0 and max <= tonumber(redis.call('GET', KEYS[1]) or '0') then local pttl = redis.call('PTTL', KEYS[1]) if pttl > 0 then return pttl end end return 0
The script maps one to one onto the pattern: KEYS[1] is emq:{q}:limiter, the
counter; the limit is read from the emq:{q}:meta hash field max (or passed
directly); when the counter has reached max and the window still has time, the remaining
PTTL is returned as the over-budget back-off. EchoMQ.Metrics.get_global_rate_limit/2
reads that configured cap on its own (HGET emq:{q}:meta max, 0 when unconfigured).
When a limit is hit, EchoMQ.Meter.rate_limit_hit/2 emits [:emq, :rate_limit, :hit]
with %{delay: delay} — the telemetry receipt that work was held back, at zero cost when
:telemetry is not loaded.
{:ok, ttl} = EchoMQ.Metrics.get_rate_limit_ttl(conn, "orders", 0)
# → {:ok, 740} # 740 ms left in the window — the back-off a job over budget waits
[:emq, :rate_limit, :hit] %{delay: 740} %{queue: "orders"} # the receipt
Rate-limiting caps requests to a budget per window: count admissions in the current window, refuse once the count reaches the limit, and the time left on the window is the back-off.
The emq:{q}:limiter counter spent down to max (from emq:{q}:meta)
over its own PTTL window; EchoMQ.Metrics.get_rate_limit_ttl/3 reads the remaining
window — the over-budget back-off — and [:emq, :rate_limit, :hit] is the receipt.
The counter and its window are the shipped primitive. The enforcement loop — incrementing the counter on each claim and sending an over-budget job back to wait — is the scaling layer, taught in the EchoMQ course rather than here. The read surface above answers how much budget is left; wiring that into the dequeue point is the deeper protocol work the door below covers.
The limiter at the claim point
The Queue pillar absorbs the scaling layer: the limiter wired into the dequeue path, fair lanes that serve every tenant in turn, and the per-group concurrency ceiling.
/bcs/busThe bus the counter rides
Part B3 builds the Valkey-native bus and its fair-lane architecture: the emq:{q}: keyspace,
jobs and lanes, and the system that gates every ingress on one branded namespace.
Where the budget endures
A limiter counter is volatile by design — it expires and resets. The durable substrate beneath the bus, and the dial a system turns, is taught in full there.
Notes on ValkeyThe counter carries the per-queue {q} hash
tag, so emq:{q}:limiter and emq:{q}:meta hash to one of 16384 slots — co-located
on one node, which is what lets the read script touch both keys without a CROSSSLOT error —
valkey.io/topics/cluster-spec.
When to use / when to avoid
the decisionReach for a rate limiter when:
- A shared service where one caller, tenant, or runaway loop could consume capacity others need.
- An external dependency with its own published limits you must stay under — a third-party API, an outbound message channel.
- Fair access across tenants on one cluster, where each tenant gets a budget per window.
- Protecting a backend from a thundering herd by capping admitted work to what the backend can absorb.
Avoid it when:
- The constraint is concurrency, not rate — a ceiling on simultaneous in-flight work is a
different primitive (a count of active leases, EchoMQ's
glimit/gactiveper-lane ceiling), not a per-window count. - Exact fairness down to the request matters more than throughput — a fixed window's boundary burst admits up to twice the limit across the seam; reach for a sliding window or a token bucket instead.
- The limited resource is purely local to one process and never shared — an in-process guard is lighter than a round trip to the store.
- The cost of a refused request (a dropped event, a lost signal) is higher than the cost of letting it through — back-pressure or a queue may serve better than a hard refusal.
Rate-limiting bounds a rate per window; a concurrency ceiling bounds simultaneity. They look alike and solve different problems — the limiter caps how often, the ceiling caps how many at once.
References
sources · relatedSources
- Valkey — INCR — atomic increment-and-return; the counter primitive, creating a missing key at
0first. - Valkey — PEXPIRE — set a key's lifetime in milliseconds; the window is the key's own TTL.
- Valkey — PTTL — read milliseconds remaining on a key; the over-budget back-off, read directly.
- Valkey — SET —
PXandNXfor an atomic first-write of value and expiry together. - Valkey — Cluster specification — the
{q}hash tag pins the counter to one of 16384 hash slots. - Stripe — Scaling your API with rate limiters — the rate-vs-concurrency distinction and the token-bucket policy in production.
Related in this course
- R6 · Flow Control & Scalethe chapter
- R6.01.1 · Fixed & sliding windowsthe window algorithms and the boundary burst
- R6.01.2 · Token & leaky bucketsthe burst-aware and rate-smoothing buckets
- R6.01.3 · Global, not localwhy the counter lives in the store
- /echomq/queuethe limiter wired at the claim point, fair lanes, concurrency
- /bcs/busPart B3, the Valkey-native bus the counter rides