Rate limiting allows a configured number of executions within a time window. Calls run immediately while capacity remains. Once the limit is reached, later calls are rejected until capacity becomes available again.
TanStack Pacer provides an in-memory rate limiter intended primarily for client-side operations. It can run in server-side JavaScript, but it is not a distributed quota or enforcement system.
This example allows three executions per window:
Rate Limiting (limit: 3 calls per window)
Timeline: [1 second per tick]
Window 1 | Window 2
Calls: ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️ ⬇️
Executed: ✅ ✅ ✅ ❌ ❌ ✅ ✅
[=== 3 allowed ===][=== blocked until reset ===][=== new window ===]Rate limiting permits bursts. It does not space accepted calls evenly.
Choose rate limiting when:
Choose another utility when:
The windowType option controls when capacity returns.
A fixed window starts when its first execution is accepted. All accepted executions remain counted until that window ends. Capacity then resets together.
import { RateLimiter } from '@tanstack/ember-pacer'
const limiter = new RateLimiter(sendEvent, {
limit: 3,
window: 1000,
windowType: 'fixed',
})Fixed windows can allow bursts near a boundary because a full quota becomes available when the window resets.
A sliding window tracks each accepted execution separately. Capacity returns one execution at a time as old timestamps leave the window.
Sliding Window (limit: 3 calls per window)
Timeline: [1 second per tick]
Calls: ⬇️ ⬇️ ⬇️ ⬇️ ⬇️
Executed: ✅ ✅ ✅ ❌ ✅
[=== full ===][oldest execution expires][=== one available ===]import { RateLimiter } from '@tanstack/ember-pacer'
const limiter = new RateLimiter(sendEvent, {
limit: 3,
window: 1000,
windowType: 'sliding',
})Use a sliding window when capacity should return gradually rather than all at once.
Call maybeExecute() for quota-controlled operations. Use state or value helpers for UI updates, and the instance for capacity helpers and rejection state.
Invoke the helper in a template. Named arguments supply options, and the second positional argument selects state. Removing the helper invocation runs cleanup. The example imports application operations from ./api.
import Component from '@glimmer/component'
import { on } from '@ember/modifier'
import { fn } from '@ember/helper'
import { useRateLimiter } from '@tanstack/ember-pacer'
import type { RateLimiterState } from '@tanstack/ember-pacer'
import { sendEvent } from './api'
const select = (state: RateLimiterState) => ({
rejectionCount: state.rejectionCount,
executionCount: state.executionCount,
})
export default class Example extends Component {
<template>
{{#let (useRateLimiter sendEvent select limit=3 window=10000) as |limiter|}}
<button {{on 'click' (fn limiter.maybeExecute 'clicked')}}>Send</button>
<output>{{limiter.state.rejectionCount}}</output>
{{/let}}
</template>
}The focused TypeScript snippets below demonstrate the core RateLimiter class re-exported by the adapter. In a component, use useRateLimiter as above to own the instance, pass configuration as named arguments, and pass the yielded instance to event handlers. Core class instances require explicit cleanup.
Use the yielded utility's maybeExecute method when an event should invoke a rate-limited side effect:
import { on } from '@ember/modifier'
import { fn } from '@ember/helper'
import { useRateLimiter } from '@tanstack/ember-pacer'
// In a component template; this.search accepts a query string:
<template>
{{#let (useRateLimiter this.search limit=3 window=1000) as |search|}}
<button {{on 'click' (fn search.maybeExecute @query)}}>Search</button>
{{/let}}
</template>The callback returns whether the call was accepted. It does not expose capacity helpers or reset(). Use useRateLimiter when the component needs that control.
Use useRateLimitedState when Pacer should own the rate-limited state, or useRateLimitedValue when a value already changes elsewhere:
import { useRateLimitedValue } from '@tanstack/ember-pacer'
<template>
{{#let (useRateLimitedValue @query limit=3 window=1000) as |delayed|}}
<SearchResults @query={{delayed.value}} />
{{/let}}
</template>Rejected calls do not run later. Use the boolean return value or onReject to provide feedback, retry elsewhere, or place work into a queue.
import { RateLimiter } from '@tanstack/ember-pacer'
const limiter = new RateLimiter(sendEvent, {
limit: 2,
window: 1000,
onReject: (limiter) => {
console.log('Rejected calls:', limiter.store.state.rejectionCount)
},
})If rejected operations must eventually run, a queuer is usually a better fit.
The instance API provides two computed helpers:
limiter.getRemainingInWindow() // Accepted executions still available.
limiter.getMsUntilNextWindow() // Time until at least one execution is available.Both helpers use the current limit, window, windowType, and execution history.
reset() clears execution timestamps, counters, and cleanup timers. The next call starts with full capacity.
limiter.reset()Use setOptions() to update the configuration:
limiter.setOptions({
limit: 10,
window: 30_000,
})Changing options does not erase existing execution history. Call reset() when the new configuration should begin with a fresh window.
The enabled, limit, and window options may be functions that receive the limiter instance:
import { RateLimiter } from '@tanstack/ember-pacer'
const limiter = new RateLimiter(sendEvent, {
enabled: (limiter) => limiter.store.state.executionCount < 100,
limit: (limiter) => (limiter.store.state.rejectionCount > 10 ? 2 : 5),
window: 60_000,
})Disabling the limiter prevents the wrapped function from executing. It does not delete existing execution history.
onExecute receives the executed arguments and limiter instance. onReject receives the limiter instance.
import { RateLimiter } from '@tanstack/ember-pacer'
const limiter = new RateLimiter(sendEvent, {
limit: 5,
window: 1000,
onExecute: (args, limiter) => {
console.log('Sent:', args)
console.log('Remaining:', limiter.getRemainingInWindow())
},
onReject: (limiter) => {
console.log('Rejected:', limiter.store.state.rejectionCount)
},
})The adapter has no default operation cleanup because a synchronous limiter has no pending or active work. Use onUnmount only when the component needs custom teardown related to the limiter.
The adapter subscribes only to the state returned by the selector argument. Without a selector, the adapter state is empty. Use the helper's second positional argument to select fields, as shown above. Read those fields from the yielded instance's .state in the template.
import Component from '@glimmer/component'
import { on } from '@ember/modifier'
import { fn } from '@ember/helper'
import { useRateLimiter } from '@tanstack/ember-pacer'
import type { RateLimiterState } from '@tanstack/ember-pacer'
import { sendEvent } from './api'
const select = (state: RateLimiterState) => ({
rejectionCount: state.rejectionCount,
executionCount: state.executionCount,
})
export default class Example extends Component {
<template>
{{#let (useRateLimiter sendEvent select limit=3 window=10000) as |limiter|}}
<button {{on 'click' (fn limiter.maybeExecute 'clicked')}}>Send</button>
<output>{{limiter.state.rejectionCount}}</output>
{{/let}}
</template>
}The contextual utility.Subscribe helper selects state for a child template without subscribing the utility owner.
Option functions and lifecycle callbacks receive the underlying public utility instance. The .store.state reads inside those callbacks in the examples above are supported. Rendering code should read the selected adapter state shown here.
To restore selected state that your app has persisted, pass a partial snapshot through initialState. It is merged with the defaults. Restore only durable fields. Pending timers are not restored.
Commonly useful state includes:
See the Ember API reference for adapter signatures and the public core reference for complete option and state types.