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:

  1. Debounce the save operation.
  2. Pass an AbortSignal to the HTTP request.
  3. Cancel pending work when the user discards a draft.
  4. 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.