title: "TanStack Pacer: The Debounce/Throttle Library That Finally Ships Observable State" description: "TanStack Pacer complete guide 2026: debounce, throttle, rate limit, queue, and batch patterns with real production code. 57M downloads. Observable state included." author: "Huifer" authorUrl: "https://tanstackship.com/about" date: "2026-09-18" lastUpdated: "2026-09-18" tags: ["TanStack Pacer", "TanStack", "Rate Limiting", "Debounce", "Throttle", "React", "Performance"] readTime: "9 min read" slug: "tanstack-pacer-complete-guide-2026" canonical: "https://tanstackship.com/blog/tanstack-pacer-complete-guide-2026" profile: "deep-dive" eeat: rule: word_count: 1950 word_count_pts: 8 hero_block_pts: 4 heading_structure_pts: 3 internal_links_pts: 3 code_blocks_pts: 2 total: 20 llm: experience: 17 expertise: 18 authoritativeness: 18 trustworthiness: 18 total: 71 rationale: "Comprehensive production guide covering all five Pacer patterns with real code. Author demonstrates first-hand use of debounce for search, throttle for analytics, rate limiting for payment APIs, and queue for batch operations across 12+ production apps." total: 91 passed: true weak_signals: ["Beta API means imports may shift before 1.0"] strong_signals: ["All five patterns covered with working code", "Observable state integration shown explicitly", "Async variants with retry/backoff demonstrated", "Framework hooks included for React"] legacy_total: 91 core_eeat: framework: "CORE-EEAT" profile: "blog-post" catalog_version: "18.0.0" observed_at: "2026-09-18" verdict: "FIX" status: "DONE" score_state: "SCORED" raw_overall_score: 79 final_overall_score: 79 veto_count: 0 cap_applied: false evidence_coverage: 100 score_confidence: "high" dimension_scores: C: 75.0 O: 81.25 R: 90.0 E: 80.0 Exp: 81.25 Ept: 90.0 A: 50.0 T: 81.25 run_json: "2026-09-18-tanstack-pacer-complete-guide-2026.core-eeat.run.json"
Written by Huifer, solo developer and maintainer of TanStack Ship. I've shipped 12+ SaaS apps on Cloudflare Workers since 2024. I hit rate limit errors on a Stripe API call in Q4 2025 — my billing dashboard fired 40+ requests per second on page load. I rewrote it with TanStack Pacer's rate limiter in 45 minutes. The fix held for 8 months across 3 apps. I also use debounce for every search input, throttle for analytics scroll tracking, and the queue pattern for batched webhook processing. These patterns are in production on TanStack Ship today.
Verified sources: TanStack Pacer on npm · TanStack Pacer Official Docs · TanStack Pacer GitHub · TanStack Store Last updated: 2026-09-18 · Changelog
TL;DR: TanStack Pacer (@tanstack/pacer, @57M downloads, ~3.4M weekly) gives you debounce, throttle, rate limiting, queuing, and batching as a single coherent API with observable state. One import, one mental model, works in React and Solid. This is the complete production guide.
The Problem That Made Me Reach for a Library
In November 2025, my billing dashboard was generating 40–60 API calls in the first 200ms of page load. The component fired events as multiple subscriptions resolved simultaneously, each triggering a mutation. The Stripe API responded with 429 Too Many Requests on load, silently dropping billing events.
My first fix was a 6-line setTimeout-based debounce. It worked for a week. Then I needed pending state in the UI. Then I needed cancellation. Then I needed the same pattern for scroll tracking, where I wanted throttle not debounce. The 6 lines became 60 lines of fragile timer management spread across 4 files.
I installed TanStack Pacer. The 60 lines became 12, the pending state came for free, and I had the same API for every timing pattern.
That is the pitch: not just the timer, but the whole observable model around it.
What TanStack Pacer Actually Does
TanStack Pacer provides five execution-timing policies. The right choice depends on one question: what can your workflow afford to lose?
| Policy | Keeps | Tradeoff | Best for |
|---|---|---|---|
| Debounce | The latest call only | Middle calls discarded | Search inputs, autosave |
| Throttle | A regular sample | Middle calls discarded | Scroll tracking, resize handlers |
| Rate Limit | Calls inside allowance | Excess calls rejected | External API protection |
| Queue | Every task | Sequential, no concurrency | File uploads, sequential processing |
| Batch | Every item | Grouped execution | Analytics writes, bulk operations |
The key insight: all five patterns share the same observable state model. Pacer builds on TanStack Store, so every utility exposes execution counts, pending state, and error status as reactive values you can render directly in the UI — no separate state management required.
Install with:
npm install @tanstack/pacer @tanstack/react-pacer
Debouncing: Waiting for the Quiet Moment
Debouncing delays execution until calls stop arriving for a configured interval. Only the final call in a burst runs. Use it when intermediate calls are throwaway — the last value is all that matters.
Classic use case: search autocomplete. A user typing "tanstack" fires 8 keystrokes in 400ms. Without debounce: 8 API calls. With 300ms debounce: 1 API call.
import { debounce } from '@tanstack/pacer'
const search = debounce(
async (query: string) => {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error(`Search failed: ${res.status}`)
return res.json()
},
{ wait: 300 },
)
// These 8 calls in 400ms → only the last one fires after 300ms of silence
search('t')
search('ta')
search('tan')
search('tans')
search('tanst')
search('tansta')
search('tanstac')
search('tanstack')
For React, the hook version integrates with reactive state:
import { useDebounce } from '@tanstack/react-pacer'
import { useQuery } from '@tanstack/react-query'
function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
const { data } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchApi(debouncedQuery),
enabled: debouncedQuery.length > 0,
})
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}
The hook gives you the debounced reactive value directly. No useEffect, no setTimeout cleanup, no stale closure bugs.
Throttling: Keeping a Steady Rhythm
Throttling fires at most once per interval, evenly spaced. Use it when every call is meaningful but you need to cap the frequency — throttle samples the signal, debounce waits for silence.
Best for: scroll handlers, resize observers, analytics tracking. The user's scroll position is always relevant; you just do not need 200 events per second.
import { throttle } from '@tanstack/pacer'
const trackScroll = throttle(
(position: number) => {
analytics.track('scroll_depth', { position })
},
{ wait: 500, leading: true, trailing: true },
)
window.addEventListener('scroll', () => {
trackScroll(window.scrollY)
})
leading: true fires immediately on the first call. trailing: true fires once after the quiet period. With both enabled, you get a first call plus a final call after the burst — useful for scroll tracking where you want both the start and the end.
Rate Limiting: Protecting External APIs
Rate limiting enforces a hard ceiling: N calls per time window. Excess calls are rejected, not delayed. Use it when an external service dictates the budget — Stripe, OpenAI, a third-party webhook API.
This is what fixed my billing dashboard in November 2025:
import { createRateLimiter } from '@tanstack/pacer'
// Stripe allows 100 read requests/second on the Balance API
const stripeLimiter = createRateLimiter({
limit: 100,
window: 1000, // 1 second
})
async function fetchBalance() {
const allowed = await stripeLimiter.maybeExecute(async () => {
return stripe.balance.retrieve()
})
if (!allowed.ok) {
// Rate limited — schedule retry
console.warn(`Rate limited, retry in ${allowed.retryAfter}ms`)
setTimeout(fetchBalance, allowed.retryAfter)
return null
}
return allowed.value
}
The maybeExecute call returns a result that tells you whether the call ran or was rejected, with a retryAfter value for scheduling backoff. This is observable: you can wire the rejection count to a UI indicator without extra state.
Queuing: When Every Call Must Run
Queuing runs every task, in order, with configurable concurrency. Use it when dropping calls is not acceptable — file uploads, sequential webhook processing, order confirmation flows.
import { Queue } from '@tanstack/pacer'
const uploadQueue = new Queue({
concurrency: 2, // 2 uploads in parallel
maxWait: 30000, // 30s timeout per item
onError: (error, item) => {
console.error(`Upload failed for ${item.name}:`, error)
notifyUser(`Upload of ${item.name} failed`)
},
onSettled: (result, item) => {
updateProgress(item.id, 'complete')
},
})
// Add files from a drag-and-drop handler
fileList.forEach(file => {
uploadQueue.add(async () => {
updateProgress(file.id, 'uploading')
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/upload', { method: 'POST', body: formData })
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
return res.json()
})
})
uploadQueue.start()
// In the UI, you can read the queue state:
uploadQueue.store.subscribe(state => {
renderProgress({ pending: state.pending, settled: state.settled })
})
Priority ordering is available for queue items, and the queue can be paused, resumed, and cleared. If a user cancels a batch upload mid-flight, queue.clear() drops pending items and queue.pause() stops new starts without dropping active work.
Observable State: The Feature Nobody Else Ships
Most debounce/throttle implementations are a timer and a function. Pacer builds on TanStack Store and exposes execution state as observable values:
import { debounce } from '@tanstack/pacer'
const search = debounce(searchApi, { wait: 300 })
// Read reactive state directly
search.store.subscribe(state => {
console.log('Pending:', state.isPending)
console.log('Execution count:', state.executionCount)
console.log('Error:', state.error)
})
// Render in React without useState
function SearchStatus() {
const { isPending, executionCount } = useStore(search.store)
if (!isPending && executionCount === 0) return null
return (
<span>
{isPending ? 'Searching...' : `Searched ${executionCount} times`}
</span>
)
}
This is the practical difference: the debounce result is not just a callable function, it is a live data source. The pending spinner, the execution counter, the error display — all wired from the same object, no extra useState required.
Async Variants with Retry and Abort
For operations that must succeed — saving a draft, processing a payment intent, syncing a record — use the async variants:
import { asyncDebounce } from '@tanstack/pacer'
const saveDraft = asyncDebounce(
async (content: string) => {
const res = await fetch('/api/drafts', {
method: 'POST',
body: JSON.stringify({ content }),
})
if (!res.ok) throw new Error(`Save failed: ${res.status}`)
return res.json()
},
{
wait: 1000,
asyncRetryerOptions: {
maxAttempts: 3,
backoff: 'exponential',
baseWait: 500,
jitter: 0.1,
},
},
)
// In a React autosave hook:
const handleChange = async (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
await saveDraft(e.target.value) // retries automatically on failure
}
The retryer uses exponential backoff with jitter — appropriate for transient network errors. For persistent failures, the onError callback handles the user notification. Pass a signal to the underlying fetch call to support abort on cancellation.
React Hooks: The Complete Pattern Set
TanStack Pacer ships first-class React hooks. Here is the production pattern I use for every search input:
import { useDebouncedCallback, useDebounce } from '@tanstack/react-pacer'
import { useQuery } from '@tanstack/react-query'
function ProductSearch() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 350)
const { data, isPending, error } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchProducts(debouncedQuery),
enabled: debouncedQuery.length >= 2,
staleTime: 30_000,
})
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search products..."
/>
{isPending && <Spinner />}
{error && <ErrorBanner error={error} />}
{data && <ResultsList products={data} />}
</div>
)
}
The useDebounce hook gives you a reactive value you can pass directly to useQuery's queryKey. This means TanStack Query handles caching and background refetching automatically — Pacer handles timing, Query handles data fetching. No manual coordination.
TanStack Pacer vs Alternatives
The JavaScript ecosystem has no shortage of debounce implementations. Here is how Pacer compares:
| Feature | Pacer | lodash.debounce | Custom setTimeout |
|---|---|---|---|
| Observable state | ✅ TanStack Store | ❌ | ❌ |
| DevTools | ✅ | ❌ | ❌ |
| All 5 patterns | ✅ | Debounce/throttle only | ❌ |
| Framework hooks | ✅ React + Solid | ❌ | ❌ |
| Tree-shaking | ✅ Deep imports | Partial | N/A |
| Beta status | Yes (stable API) | Stable | N/A |
| Bundle size | ~3KB core | ~1.5KB | 0KB |
Pacer is not the smallest option. For a one-off debounce in a script tag, lodash is still fine. For a production app with multiple timing patterns, observable state, and framework integration, Pacer is the coherent choice — one import, one mental model, one state source.
Production Checklist
Before shipping any Pacer implementation:
- Pick the right policy. Debounce for search, throttle for scroll, rate limit for API protection, queue for sequential work.
- Set the wait interval conservatively. 300ms is standard for search. 500ms for throttle. Tune against your p95 load time.
- Handle the rejected state in rate limiting. Calls that are rate-limited need a retry path, not silent failure.
- Use async variants for operations that must succeed. Sync debounce cannot await or retry.
- Pass abort signals for cancellable operations. Long-running fetches should stop when the component unmounts.
- Read the beta API docs before upgrading. The API is stable but still subject to minor changes before 1.0.
When to Wait on Pacer
TanStack Pacer is in beta. If you are evaluating it for a new project, the beta status means:
- APIs are unlikely to break in a backwards-incompatible way, but review the changelog on upgrade.
- React and Solid adapters are production-ready. Angular, Preact, Svelte, and Vue adapters need contributors.
- The
pacer-litevariant ships the same API without reactivity hooks — useful for publishing libraries.
For anything shipping to real users today, the beta is safe. For a team that needs a hard 1.0 commitment, lodash is the conservative choice.
Pacer is in TanStack Ship by default for every new project. If you are evaluating SaaS boilerplates, see how Pacer integrates with the TanStack Start routing model and the full feature list.