Packages
throttlebeam
0.1.0
Keyed debounce and throttle primitives on OTP, no external dependencies.
Current section
Files
Jump to
Current section
Files
throttlebeam
README.md
README.md
# Throttlebeam
Keyed debounce and throttle primitives on top of OTP.
## Debounce vs Throttle
These terms are frequently confused. Here is the distinction:
```
DEBOUNCE: "fire AFTER quiet" THROTTLE: "fire AT MOST every N"
+ trailing call at end
events: --|--|--|-- events: --|--|--|--|--|--|
suppressed
quiet trailing
(target wait) (last intent preserved)
use when: autosave, search-as-you-type rate-limit API calls, smooth
coalesce webhook bursts event stream spikes
```
**Debounce** waits for a quiet period after the last call before firing.
Each new call resets the clock. If you're building a search-as-you-type
box, debounce is what you want — fire the query only after the user
stops typing.
**Throttle** fires immediately on the first call, then suppresses further
calls for the duration of the interval. A trailing call fires at the end
of the interval if any calls arrived during suppression, delivering the
most recent intent. Use throttle for rate-limiting API calls or smoothing
event stream spikes.
## Installation
Add `throttlebeam` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:throttlebeam, "~> 0.1.0"}
]
end
```
## Usage
```elixir
# Start a named server under your supervision tree
children = [
{Throttlebeam, name: MyApp.Throttle, max_keys: 50_000}
]
{:ok, _pid} = Supervisor.start_link(children, strategy: :one_for_one)
# Debounce: fire autosave after 500ms of quiet on this user's key
:ok = Throttlebeam.debounce(MyApp.Throttle, "user:123", 500, fn ->
MyApp.Repo.autosave(user_id)
end)
# Throttle: fire search at most every 200ms
:ok = Throttlebeam.throttle(MyApp.Throttle, "search:123", 200, fn ->
MyApp.Search.run(query)
end)
# Cancel a pending timer (e.g. user navigated away)
:ok = Throttlebeam.cancel(MyApp.Throttle, "user:123")
# Inspect a key's state (for debugging/tests)
{:ok, %{mode: :debounce, generation: 3, pending: true}} =
Throttlebeam.inspect(MyApp.Throttle, "user:123")
```
## Configuration
Options accepted by `start_link/1`, `start_link/2`, and `child_spec/1`:
| Option | Default | Description |
|---|---|---|
| `:name` | — | Atom to register the server process |
| `:max_keys` | 10 000 | Maximum distinct keys per instance |
| `:max_delay` | 60 000 | Maximum allowed delay/interval in ms |
| `:staleness_window_ms` | 300 000 | Idle entries older than this are reaped by sweep |
| `:sweep_interval_ms` | 60 000 | How often the cleanup sweep runs |
## Guarantees
- **No double fires**: generation-tagged timer messages prevent cancelled timers
from causing duplicate invocations.
- **No missed fires**: the trailing call in throttle guarantees the most recent
intent during a suppressed window is never silently dropped.
- **Key isolation**: one key's slow or failing callback does not affect other keys.
- **Zero runtime dependencies**: built entirely on OTP primitives.
## Error semantics
| Error | Meaning |
|---|---|
| `{:error, :bad_arg}` | `fun` is not a 0-arity function or `delay` is not a non-negative integer |
| `{:error, :delay_too_large}` | delay exceeds the server's `:max_delay` |
| `{:error, :mode_conflict}` | key is already in the other mode (debounce vs throttle) |
| `{:error, :max_keys_reached}` | key is new and the server's key cap is full |
## Callbacks
Callbacks execute **inline** in the server process. A slow callback will
delay processing of other keys. Keep callback bodies fast, or enqueue
heavy work elsewhere before invoking the library.
Errors raised, thrown, or exited from callbacks are caught and logged
via `Logger.error/1` — one bad callback will not crash the shared server.
## License
MIT — see [LICENSE](LICENSE).