DEV Community

Mohammad Garmabi
Mohammad Garmabi

Posted on

Stop Sending Too Many API Requests in React: 6 TanStack Pacer Patterns

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
Enter fullscreen mode Exit fullscreen mode

Create a shared client:

import { createFetch } from 'tanstack-fetch'

export const api = createFetch({
  baseUrl: import.meta.env.VITE_API_URL,
  timeoutMs: 10_000,
})
Enter fullscreen mode Exit fullscreen mode

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>
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  )
Enter fullscreen mode Exit fullscreen mode

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 },
)
Enter fullscreen mode Exit fullscreen mode

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`)
    },
  },
)
Enter fullscreen mode Exit fullscreen mode

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),
  }),
})
Enter fullscreen mode Exit fullscreen mode

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),
  },
)
Enter fullscreen mode Exit fullscreen mode

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' },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)