DEV_NET_CORE
GET_STARTED
ReactForms, validation, and frontend performance in production

Preventing duplicate requests, canceling stale requests, and avoiding race conditions

Overview

Preventing duplicate requests, canceling stale requests, and avoiding race conditions are core skills for building reliable React applications. These problems happen when user input, route changes, effects, retries, form submissions, and background refetches overlap.

Duplicate requests waste bandwidth and can create inconsistent loading states. Stale requests are requests that were useful when started but no longer match the current UI state. Race conditions happen when multiple async operations complete in an unexpected order and the wrong result wins.

This topic matters in search boxes, route loaders, autosave flows, form submissions, dependent dropdowns, authentication refresh, optimistic updates, pagination, and background data refresh. It is especially important when latency is variable.

For interviews, this topic tests whether a candidate understands React effects, cleanup, AbortController, request identity, data library deduplication, route data behavior, and safe mutation handling.

Core Concepts

Duplicate Requests

Duplicate requests happen when the same request is started more times than needed.

Common causes:

  • Fetching in multiple components that need the same data.
  • Effects running again because dependencies are unstable.
  • Button double-clicks.
  • Search input firing on every keystroke.
  • Route component fetch plus route loader fetch.
  • Strict Mode exposing missing cleanup in development.
  • Retrying without a clear policy.

Duplicate requests are not always bugs. Sometimes a route and a widget intentionally fetch different projections. The problem is accidental duplication.

Stale Requests

A stale request is an in-flight request whose result no longer matches current UI state.

Examples:

  • User searches react, then changes query to react hook form.
  • User navigates from /users/1 to /users/2.
  • User closes a modal while its request is still loading.
  • User changes country before postal-code validation returns.
  • User logs out while a background request is running.

If stale responses update state, the UI can show incorrect data.

Race Conditions

A race condition occurs when correctness depends on timing.

Example:

Code
Request A starts for user 1.
Request B starts for user 2.
Request B finishes first and shows user 2.
Request A finishes later and overwrites the UI with user 1.

The user now sees stale data because the older request completed last.

The fix is to tie each response to the state that created it, cancel old requests, or let a data/router library manage request identity.

AbortController

AbortController lets code cancel fetch requests and other abortable async work.

Code
useEffect(() => {
  const controller = new AbortController();

  async function loadUser() {
    const response = await fetch(`/api/users/${userId}`, {
      signal: controller.signal,
    });
    const user = await response.json();
    setUser(user);
  }

  loadUser().catch((error) => {
    if (error.name !== "AbortError") {
      setError(error);
    }
  });

  return () => controller.abort();
}, [userId]);

When userId changes or the component unmounts, cleanup aborts the old request.

Ignore Flag Pattern

Not all async work is abortable. In those cases, use a stale-response guard.

Code
useEffect(() => {
  let ignore = false;

  async function loadUser() {
    const user = await fetchUser(userId);

    if (!ignore) {
      setUser(user);
    }
  }

  loadUser();

  return () => {
    ignore = true;
  };
}, [userId]);

This does not stop the network work, but it prevents stale completion from updating state.

Request Identity

Every async result should match the input that created it.

For search:

Code
const requestQuery = query;
const results = await search(requestQuery);

if (requestQuery === latestQueryRef.current) {
  setResults(results);
}

For route params:

Code
const requestUserId = userId;
const user = await fetchUser(requestUserId);

if (requestUserId === currentUserIdRef.current) {
  setUser(user);
}

Request identity can be a query string, route param, page number, filter object, token version, or mutation id.

Stable Effect Dependencies

Effects can duplicate requests when dependencies are unstable.

Bad:

Code
useEffect(() => {
  fetchUsers({ page, filters });
}, [{ page, filters }]);

The object literal is new on every render, so the effect runs again.

Better:

Code
const queryArgs = useMemo(() => ({ page, filters }), [page, filters]);

useEffect(() => {
  fetchUsers(queryArgs);
}, [queryArgs]);

Even better, if using a data library, make the query key explicit and stable:

Code
useQuery({
  queryKey: ["users", page, filters],
  queryFn: ({ signal }) => fetchUsers({ page, filters, signal }),
});

Data Library Deduplication

Libraries such as TanStack Query and RTK Query reduce duplicate requests by caching data by query key or endpoint argument.

Example:

Code
function useUser(userId: string) {
  return useQuery({
    queryKey: ["user", userId],
    queryFn: ({ signal }) => fetchUser(userId, signal),
  });
}

If multiple components ask for the same user with the same query key, the library can share cached data and in-flight requests.

This only works when query keys are stable and accurately represent the data.

Query Cancellation

Modern data libraries often pass an AbortSignal into the query function.

Code
useQuery({
  queryKey: ["search", query],
  queryFn: ({ signal }) => searchProducts(query, signal),
});

Transport function:

Code
async function searchProducts(query: string, signal?: AbortSignal) {
  const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
    signal,
  });

  return response.json() as Promise<Product[]>;
}

If the query becomes unused or outdated, the library can cancel or ignore it depending on configuration and transport support.

React Router Race Handling

Route data routers help manage navigation races. When a new navigation starts, old loader results should not overwrite newer navigation results. Fetcher behavior also needs to account for concurrent submissions and revalidation.

Still, route code must cooperate:

  • Use route loaders for route data instead of nested effects when possible.
  • Respect request.signal inside loaders.
  • Keep URL search params as the source of truth for route filters.
  • Avoid duplicating loader data fetches in child components.

Example loader:

Code
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const query = url.searchParams.get("q") ?? "";

  const results = await searchProducts(query, request.signal);

  return { query, results };
}

The request.signal connects route navigation cancellation to the underlying fetch.

Preventing Duplicate Submissions

Duplicate mutations can be more dangerous than duplicate reads.

Common protections:

  • Disable submit button while pending.
  • Use isSubmitting, mutation pending state, or navigation state.
  • Use idempotency keys for create/payment operations.
  • Debounce or throttle non-critical actions.
  • Server enforces idempotency and uniqueness.
  • Ignore repeated clicks for the same pending operation.

Example:

Code
<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "Saving..." : "Save"}
</button>

Client disabling improves UX, but the server should still defend against duplicate mutation requests.

Autosave and Ordering

Autosave can create write races.

Risk:

  • Save older value.
  • Save newer value.
  • Older request finishes last.
  • Server stores old value.

Mitigations:

  • Queue saves and run one at a time.
  • Use version numbers or ETags.
  • Use optimistic concurrency on the server.
  • Abort stale saves if safe.
  • Save patches with ordering metadata.
  • Reconcile conflicts explicitly.

Debounce reduces request count, but it does not guarantee ordering correctness.

Search and Filtering

Search needs both rate limiting and race handling.

Good pattern:

  • Keep input immediate.
  • Debounce the query used for requests.
  • Cancel old requests with AbortController.
  • Include query in the request identity.
  • Ignore stale responses.
  • Show loading state tied to the current query.

Avoid:

  • Request on every keystroke without debounce.
  • Letting old results overwrite new results.
  • Showing aborted requests as errors.
  • Clearing useful results during every background search unless product requires it.

Dependent Requests

Dependent dropdowns are race-prone.

Example:

  • User selects country.
  • App loads states.
  • User quickly selects a different country.
  • First states request finishes late.

Use request identity:

Code
useEffect(() => {
  const controller = new AbortController();

  loadStates(countryId, controller.signal).then((states) => {
    setStates(states);
  });

  return () => controller.abort();
}, [countryId]);

Also clear or reset dependent values when parent values change.

Authentication Refresh Races

Token refresh can cause duplicate and stale requests.

Common issues:

  • Multiple requests fail with 401 and all start refresh.
  • Old refresh response overwrites newer token.
  • Original request retries with stale token.
  • Logout happens while refresh is in flight.

Mitigation:

  • Use a refresh queue or shared refresh promise.
  • Retry original requests once.
  • Clear queues on logout.
  • Track active session version.
  • Do not refresh for login or refresh endpoints.

Auth races deserve extra care because they affect security and user trust.

Common Mistakes

Common mistakes include:

  • Fetching the same data in parent and child components.
  • Ignoring effect cleanup.
  • Not passing AbortSignal to fetch or Axios.
  • Treating aborted requests as user-facing errors.
  • Using unstable objects in effect dependencies.
  • Using query keys that do not include all relevant inputs.
  • Retrying mutations blindly.
  • Disabling buttons on the client but not enforcing idempotency on the server.
  • Letting old search results overwrite newer ones.
  • Not clearing sensitive requests after logout.

Best Practices

Best practices include:

  • Prefer route loaders or data libraries for shared server state.
  • Use stable query keys.
  • Pass AbortSignal through the API client.
  • Clean up effects.
  • Guard non-abortable async work with stale-response checks.
  • Debounce high-frequency reads like search.
  • Disable duplicate submissions while pending.
  • Use idempotency keys for important mutations.
  • Keep URL params as source of truth for route filters.
  • Test fast input, slow network, navigation during fetch, duplicate clicks, and logout during requests.

Interview Practice

PreviousDebounce and throttle for search, filtering, autosave, and expensive UI updatesNext UpReact Hook Form fundamentals, uncontrolled inputs, `Controller`, and form state