If your React app feels slow, the problem might not be the HTTP client.
It might be that you're sending requests too often.
A search box can fire on every keystroke. Autosave can create overlapping mutations. Infinite scrolling can trigger repeated calculations. Bulk uploads can overwhelm an API. Analytics can generate dozens of small requests.
TanStack Pacer helps control when and how frequently work runs. Combine it with TanStack Query and tanstack-fetch to separate timing, caching, and HTTP transport.
I'm the creator of tanstack-fetch, an independent community project—not an official TanStack package.
The architecture
| Concern | Tool |
|---|---|
| Debounce, throttle, queue, batch, rate-limit | TanStack Pacer |
| Server-state cache and query lifecycle | TanStack Query |
| HTTP requests and typed errors | tanstack-fetch |
Install the packages:
npm install @tanstack/react-pacer @tanstack/react-query tanstack-fetch
Create a shared client:
import { createFetch } from 'tanstack-fetch'
export const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
timeoutMs: 10_000,
})
The following snippets illustrate the intended patterns. Verify API signatures against the versions installed in your project.
1. Debounce search without breaking Query
Debounce the value that goes into the query key, not the queryFn.
import { useState } from 'react'
import { useDebouncedValue } from '@tanstack/react-pacer'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { api } from '~/lib/api'
export function SearchUsers() {
const [search, setSearch] = useState('')
const [debouncedSearch] = useDebouncedValue(search, {
wait: 300,
})
const query = useQuery({
queryKey: ['users', 'search', debouncedSearch],
queryFn: ({ signal }) =>
api.get<User[]>('/users', {
query: { q: debouncedSearch },
signal,
}),
enabled: debouncedSearch.trim().length >= 2,
placeholderData: keepPreviousData,
staleTime: 30_000,
})
return (
<>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
{query.isFetching && <span>Searching...</span>}
{query.data?.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</>
)
}
The input remains responsive. Query receives settled search terms, caches results per key, and can cancel obsolete requests when the query function consumes its signal.
2. Autosave with an abortable request
Autosave needs more than a delay. It needs a strategy for pending edits, in-flight requests, and cache synchronization.
The pattern is:
- Debounce the save operation.
- Pass an
AbortSignalto the HTTP request. - Cancel pending work when the user discards a draft.
- Update the Query cache after a successful response.
With Pacer's async debouncer, the core request can look like this:
const saveNote = async (draft: NoteDraft, signal: AbortSignal) =>
api.patch<Note, { id: string }, NoteDraft>(
'/notes/:id',
{
params: { id },
body: draft,
signal,
},
)
Connect this function to useAsyncDebouncer, use its cancellation APIs for discarded work, and update ['notes', id] with the returned server record.
Important: browser cancellation doesn't guarantee that the server never processed a request. For sensitive edits, use server-side version checks or optimistic concurrency.
3. Throttle infinite scrolling
Scroll handlers can execute dozens of times per second. Throttling limits how often you calculate whether the user is near the bottom.
const onScroll = useThrottledCallback(
(element: HTMLElement) => {
const nearBottom =
element.scrollTop + element.clientHeight >
element.scrollHeight - 600
if (nearBottom && hasNextPage && !isFetchingNextPage) {
void fetchNextPage()
}
},
{ wait: 200 },
)
TanStack Query should still own pagination and request state. Pacer limits the frequency of the event-driven work. An IntersectionObserver is another option for sentinel-based infinite scrolling.
4. Rate-limit calls before they hit the API
For an API with a quota of five calls per second, a client-side limiter can prevent avoidable calls.
const limiter = useAsyncRateLimiter(
(address: string) =>
api.get<Place[]>('/geocode', {
query: { address },
}),
{
limit: 5,
window: 1_000,
windowType: 'sliding',
onReject: (_args, rateLimiter) => {
const seconds = Math.ceil(
rateLimiter.getMsUntilNextWindow() / 1000,
)
toast(`Try again in ${seconds}s`)
},
},
)
This improves the client experience, but it is not a security boundary. The server must enforce its own quota.
For HTTP 429 responses, transport-level handling can respect Retry-After:
import { createFetch, parseRetryAfter } from 'tanstack-fetch'
const api = createFetch({
baseUrl: import.meta.env.VITE_API_URL,
maxRetries: 2,
onTooManyRequests: ({ context }) => ({
action: 'retry',
delayMs: parseRetryAfter(context.response?.headers, 1_000),
}),
})
5. Queue uploads with bounded concurrency
Don't open 200 simultaneous uploads just because the user selected 200 files.
const queue = useAsyncQueuer(
(job: UploadJob) =>
api.upload<Photo>('/photos', {
file: job.file,
fields: { albumId: job.albumId },
}),
{
concurrency: 3,
wait: 100,
onSuccess: (_photo, job) =>
queryClient.invalidateQueries({
queryKey: ['albums', job.albumId, 'photos'],
}),
onError: (error, job) =>
console.error(`Upload failed: ${job.file.name}`, error),
},
)
A bounded queue provides backpressure: only a few requests run at once, and the rest wait. Add progress indicators, retry policy, and per-file status for a production implementation.
6. Batch analytics events
Sending each analytics event separately adds request overhead.
const batcher = useAsyncBatcher(
(events: TrackEvent[]) =>
api.post('/events', { body: { events } }),
{
maxSize: 20,
wait: 2_000,
onError: (error, batch) =>
console.warn(`Batch of ${batch.length} events failed`, error),
},
)
batcher.addItem({
name: 'button_clicked',
at: Date.now(),
props: { page: 'dashboard' },
})
This batches events by size or elapsed time. In production, consider event deduplication and how pending events should be flushed when a page becomes hidden.
Don't retry at every layer
Retries can be configured in Query, Pacer, and the HTTP client. If all three independently retry the same operation, a single failure can multiply into many requests.
A useful division:
- HTTP status-aware retry behavior belongs at the transport layer.
- Query retries belong to query lifecycle policy.
- Pacer retries should be enabled deliberately for queued or batched tasks.
For example:
import { isFetchError } from 'tanstack-fetch'
retry: (count, error) =>
!(isFetchError(error) && error.status < 500) &&
count < 3
Adjust the policy to your API. Not every 4xx response should be treated the same way.
Quick reference
| Use case | Pacer utility |
|---|---|
| Search input | useDebouncedValue |
| Autosave | useAsyncDebouncer |
| Scroll handlers | useThrottledCallback |
| API quota | useAsyncRateLimiter |
| Upload concurrency | useAsyncQueuer |
| Analytics | useAsyncBatcher |
The takeaway
Pacer controls timing. Query controls server state. tanstack-fetch handles HTTP transport.
That separation makes the code easier to reason about than mixing debouncing, caching, cancellation, and HTTP error handling into one custom abstraction.
I'm Mohammad Garmabi, creator of tanstack-fetch, an independent community HTTP client.
Top comments (0)