Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 36 min read

Data Fetching, TanStack Query, SWR, Apollo, and Request Deduplication

A practical guide to loading server data, sharing requests, choosing cache identities, refreshing stale views, and keeping the UI honest when requests fail. The problem in one minute A shopping page displays

A practical guide to loading server data, sharing requests, choosing cache identities, refreshing stale views, and keeping the UI honest when requests fail.

The problem in one minute

A shopping page displays the same book in three places:

  • The product details panel.
  • A price summary.
  • A recommendation card.

Each component asks for product P-100. If each independently calls fetch, the page can send three identical requests.

Now the user changes the delivery region. The price changes, but one component still shows the old response. A background refresh fails and another component replaces useful cached data with a full page error.

Getting a response is easy. Coordinating identity, freshness, shared requests, errors, and updates is the harder problem.

A data fetching library helps answer:

"Do we already have this data, is a request already running, and when should we ask the server again?"

Think of a shared reception desk. Three people ask for the same document. The receptionist can make one call, share the result, and record when it needs checking again.

Table of Contents

  1. What Data Fetching Includes
  2. Server State vs UI State
  3. The Shared Request Flow
  4. Cache Keys Are Data Identities
  5. Caching, Deduplication, and Batching
  6. A Small Deduplication Example
  7. TanStack Query
  8. SWR
  9. Apollo Client
  10. Compare the Three Libraries
  11. Freshness and Background Refresh
  12. Loading, Empty, and Error States
  13. Mutations and Cache Invalidation
  14. Cancellation, Races, and Retries
  15. Parallel Requests, Pagination, and Prefetching
  16. Authentication, Server Rendering, and Cache Boundaries
  17. A Practical Decision Guide
  18. Testing and Observability
  19. Interview Questions and Answers
  20. Summary and Further Reading

Examples target TanStack Query v5, SWR v2, and Apollo Client v4 in React. They illustrate separate alternatives, not a recommendation to install all three. React and the selected library are assumed to be installed in the host application.

The Mermaid diagrams render on GitHub and in Mermaid enabled previews. REST examples assume same origin authenticated endpoints returning JSON. GraphQL examples assume the illustrated schema exists.

1. What Data Fetching Includes

What does fetch do?

The browser's fetch() sends a request and returns a promise for a response. It does not automatically build a shared application cache, decide when a view is stale, or synchronize all components using the same resource.

It also does not reject just because the response is 404 or 500. Your fetcher must check the HTTP status.

The examples below reuse this helper:

export class HttpError extends Error {
  constructor(status) {
    super(`Request failed with HTTP ${status}.`);
    this.name = 'HttpError';
    this.status = status;
  }
}

export async function getJson(url, { signal } = {}) {
  const response = await fetch(url, { signal });

  if (!response.ok) {
    throw new HttpError(response.status);
  }

  return response.json();
}

Save that helper as http.js beside the REST examples if trying them in an application. It deliberately propagates network failures, cancellation, and invalid JSON rather than returning an empty success result.

For production, validate the response shape at the API boundary. Successfully parsing JSON does not prove that priceCents is a valid number or that a required field exists.

What does the library add?

Concern Plain question
Cache "Do we already have an answer?"
Request deduplication "Is someone already asking the same question?"
Freshness "Is this answer recent enough to reuse without refreshing?"
Revalidation "Should we check for a newer answer?"
Subscription "Which components need to update when this answer changes?"
Mutation coordination "What cached answers became outdated after a write?"
Error handling "Can we retry, preserve an old answer, or show a failure?"

A library manages the client view of server data. It does not replace server authorization, database constraints, or business correctness.

Back to Top

2. Server State vs UI State

Server state is data owned by an external system. Your browser holds a possibly outdated copy.

UI state describes the local interaction.

Data Usually belongs in
Current saved product price Query cache
Server cart contents Query cache
Whether the cart drawer is open Component state or UI store
Unsaved product description Form/draft state
Search filters in a shareable URL URL state
Loading/error status of a request Query library

Why not put everything in a global store? You can, but then you must build freshness, retries, request sharing, and invalidation yourself.

Avoid copying every query response into a second store unless you have a specific ownership reason. Two copies can disagree.

A draft is different: preserve it separately so a background refresh does not overwrite what the user is typing.

Back to Top

3. The Shared Request Flow

All three components need the same product representation:

sequenceDiagram
    participant A as Product panel
    participant C as Query client
    participant API as Product API
    participant B as Price summary
    A->>C: Read product P100 in region IN
    C->>API: Start one request
    B->>C: Read the same identity while pending
    Note over B,C: Subscribe to the existing work
    API-->>C: Product response
    C-->>A: Notify with data
    C-->>B: Notify with the same result

What does the same client/cache scope mean?

Think of a query client as a reception desk. It tracks:

  • Answers already received: the cached data.
  • Requests currently running: the pending work.
  • Components waiting for or displaying those answers: the subscribers.

The scope is the group of consumers using that shared manager and its cache. For TanStack Query, components normally access it through the same QueryClientProvider and client instance. SWR uses a cache provider scope; Apollo uses an Apollo client instance.

Two components can share a request only when the library recognizes compatible request identities inside the same sharing scope.

Requirement 1: ask the same question

For TanStack Query, these keys identify the same requested data:

// Product panel
const panelKey = ['product', 'P100', 'IN'];

// Price summary
const summaryKey = ['product', 'P100', 'IN'];

The arrays are separate JavaScript objects, but TanStack Query compares their query identity by their contents, not merely by array reference.

Why are separate arrays still the same query?

In ordinary JavaScript, === checks whether two arrays are the exact same object in memory, not whether their contents match:

const first = ['product', 'P100', 'IN'];
const second = ['product', 'P100', 'IN'];
const sameReference = first;

console.log(first === second); // false: two separate array objects
console.log(first === sameReference); // true: the exact same array object

Think of two separate pieces of paper with the same address written on them. They are different pieces of paper, but they point to the same place.

TanStack Query's default key hashing generates a stable identifier from a serializable key's contents. Both arrays above produce the same query identifier, even though first === second is false.

Component A creates ['product', 'P100', 'IN'] --+
                                              +--> Same query in one shared client
Component B creates ['product', 'P100', 'IN'] --+

Within the same query client, both components subscribe to the same cached query and can share its pending request. You do not need to reuse the exact same array variable or memoize it just to make these keys match.

Array order matters: ['product', 'P100', 'IN'] and ['product', 'IN', 'P100'] are different keys. For plain objects inside a key, property order does not change the default hash. Use serializable values that clearly describe the data, not functions or arbitrary object instances.

JavaScript asks, "Is this the same array object?" TanStack Query asks, "Does this key identify the same data?"

These keys ask different questions:

const indiaKey = ['product', 'P100', 'IN'];
const usKey = ['product', 'P100', 'US'];

The region can change the price or availability, so these should not automatically share one answer. Include every relevant input, such as currency too if it can vary independently. Section 4 explains key design in more detail.

Requirement 2: ask the same reception desk

With a shared client:

Product panel ----+
                  +--> Query Client A --> one pending request --> API
Price summary ----+

When the price summary asks while the panel's request is running, the client can say:

"I am already fetching that product. Both components can use the result."

With separate clients:

Product panel --> Query Client A --> request 1 --> API

Price summary --> Query Client B --> request 2 --> API

Client B does not know about Client A's pending work, even if both components use the same key.

That is why you normally create a stable, shared browser client above related components rather than creating a new client inside each component. The provider setup in Section 7 shows this pattern.

Separate scopes can be intentional, such as isolating incompatible accounts. The goal is not to share everything globally; it is to share within the correct data and privacy boundary.

Why does another tab not automatically share the request?

Each browser tab has its own JavaScript memory:

Tab 1 --> its own query client, cache, and pending promises
Tab 2 --> its own query client, cache, and pending promises

Sharing login cookies does not make those JavaScript objects shared. Two tabs can therefore start separate application requests for the same product.

The browser's HTTP cache may still satisfy or validate a request without downloading the full response again. That is a separate layer, not proof that the tabs share a query client.

To synchronize changes across tabs, use deliberate coordination such as the mechanisms in Cross Tab Communication. Announcing a cache invalidation across tabs does not, by itself, make their pending requests one shared request.

Why does deduplication not mean fetch once forever?

Deduplication shares equivalent work according to the library's policy. It does not permanently prevent future requests:

Time Action What happens
10:00:00 Product panel requests P100 A request starts
10:00:00 Price summary asks for the same identity while it is pending It joins the existing work
10:00:01 The response arrives Both components receive the result
10:05:00 A configured refresh trigger checks the product again A new request can start

The later refresh is useful because the price or stock may have changed. This timeline is an example, not a five minute refresh timer built into every library.

After a request completes, whether a later consumer reuses the result or triggers another fetch depends on cache availability, freshness, fetch policy, and revalidation settings. SWR also has a deduplication interval, discussed in Section 8.

Easy rule: the same question + the same shared manager + overlapping work can share one request. A later refresh is a new check for current data.

Back to Top

4. Cache Keys Are Data Identities

What is a key?

A key is the label used to look up a particular answer.

These are not necessarily the same answer:

Product P100 for region IN, currency INR
Product P100 for region US, currency USD

A key that includes only P100 can accidentally reuse the wrong price.

For TanStack Query, a suitable shape is:

const queryKey = [
  'product',
  { productId: 'P100', region: 'IN', currency: 'INR' },
];

Include inputs that change the returned representation:

  • Resource identifier.
  • Filters, sort order, and pagination cursor.
  • Region, locale, or currency when relevant.
  • Tenant or account scope for private data.

Do not put raw access tokens or other secrets in keys. Keys may appear in debugging tools, logs, and persisted caches. Use a nonsecret scope identifier and clear private caches when identity changes.

The important rule

The same key must mean the same data contract within its cache scope.

Using the same key with two different fetchers does not magically make their answers compatible.

GraphQL clients also use query documents, variables, field arguments, and entity identities. Apollo's normalized entity cache introduces additional identity rules explained later.

Back to Top

5. Caching, Deduplication, and Batching

These reduce work in different ways:

Mechanism What it does Example
Result caching Reuses a completed answer Product was fetched earlier and is still reusable
Request deduplication Shares equivalent pending work Three consumers share one product request
Batching Groups distinct operations into one transport call Ask for products P100, P200, and P300 together
Debouncing Delays work until input settles Search after typing pauses
Throttling Limits how often work starts Refresh at most once per interval
HTTP caching Reuses responses according to HTTP cache rules Browser validates an ETag or uses a fresh cached response

Same question shared is deduplication. Different questions packaged together is batching.

Application caches and the browser HTTP cache are separate layers. A query library might call fetch while the browser serves the response from its HTTP cache. Measure network behavior rather than assuming every query invocation reaches the server.

Deduplication is also not idempotency. Two deliberate "add one book" commands may represent two additions. Do not merge mutations merely because their payloads look identical.

Back to Top

6. A Small Deduplication Example

What is the basic mechanism?

Store the promise for pending work. If another caller asks for the same identity, return that promise instead of starting a new operation.

export function createRequestDeduper() {
  const pending = new Map();

  return function dedupe(key, load) {
    if (pending.has(key)) {
      return pending.get(key);
    }

    const request = Promise.resolve()
      .then(load)
      .finally(() => {
        pending.delete(key);
      });

    pending.set(key, request);
    return request;
  };
}

Example usage with the helper from Section 1:

const dedupe = createRequestDeduper();
const url = '/api/products/P100?region=IN&currency=INR';
const key = JSON.stringify(['product', 'P100', 'IN', 'INR']);

const [panelProduct, summaryProduct] = await Promise.all([
  dedupe(key, () => getJson(url)),
  dedupe(key, () => getJson(url)),
]);

Both consumers receive the same result object from one load. Treat that shared result as read only.

Why remove the entry after settlement?

The map represents pending requests, not a permanent cache.

  • Success removes the entry so future calls can refresh.
  • Failure also removes it so a later call can try again.
  • A synchronous exception from load becomes a rejected promise and is cleaned up too.

This teaching helper has no subscriptions, result cache, retry policy, timeout, cancellation ownership, or garbage collection for requests that never settle. It is not a replacement for a production query library.

Scope it to one compatible authenticated client context. A process global deduper on a server can mix users if the key omits identity.

Shared cancellation needs a policy: one consumer leaving should not blindly abort work that another consumer still needs. Libraries coordinate observers; the minimal helper intentionally does not accept a caller-owned abort signal.

Back to Top

7. TanStack Query

What is it, and why use it?

TanStack Query manages asynchronous server state using query keys and cached query results. React Query is its React integration; it is not restricted to REST.

It is a useful starting point when you want explicit control over freshness, invalidation, mutations, pagination, and background work.

Create one browser client for the application

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ProductPrice } from './ProductPrice';

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <ProductPrice productId="P100" region="IN" currency="INR" />
      <ProductPrice productId="P100" region="IN" currency="INR" />
    </QueryClientProvider>
  );
}

This module level browser client is for a client rendered app. Do not copy it into a server process as a shared cache for private data.

Fetch using the full identity

import { useQuery } from '@tanstack/react-query';
import { getJson, HttpError } from './http';

export function ProductPrice({ productId, region, currency }) {
  const params = new URLSearchParams({ region, currency });
  const query = useQuery({
    queryKey: ['product', { productId, region, currency }],
    queryFn: ({ signal }) =>
      getJson(
        `/api/products/${encodeURIComponent(productId)}?${params}`,
        { signal }
      ),
    staleTime: 30_000,
    gcTime: 5 * 60_000,
    retry: (failureCount, error) => {
      const retryable =
        !(error instanceof HttpError) || error.status >= 500;
      return retryable && failureCount < 2;
    },
  });

  if (query.data === undefined) {
    if (query.isError) {
      return <p role="alert">Could not load the product.</p>;
    }
    return <p>Loading product...</p>;
  }

  return (
    <section>
      <p>{query.data.priceCents / 100} {query.data.currency}</p>
      {query.isFetching && <small>Checking the latest price...</small>}
      {query.isError && (
        <p role="alert">Refresh failed. This price may be outdated.</p>
      )}
    </section>
  );
}

The two instances share the query and overlapping work through one client. The example assumes the API returns priceCents and currency.

What do these options mean?

Option Meaning
queryKey Which result the component subscribes to
queryFn How to obtain that result
staleTime How long successful data is considered fresh
gcTime How long unused query data is retained before garbage collection
retry Which failures may be retried and within what budget

staleTime does not delete data after 30 seconds or automatically create a 30 second polling timer. After that period, the data is eligible for refresh on configured triggers.

In v5, common defaults include immediate staleness, five minute garbage collection for inactive queries in the browser, and retries for failed client queries. Configure these deliberately; stale queries can refresh on mount, focus, or reconnect.

Overlapping consumers of one query can share its pending promise. staleTime helps avoid unnecessary later refreshes, but it is not a separate generic request deduplication interval.

Back to Top

8. SWR

What is it, and why use it?

SWR is a React data fetching library maintained by Vercel. Its name comes from stale-while-revalidate, a strategy that means: show a previously cached answer immediately, then check for a newer answer in the background when a refresh is triggered.

Break the name into three parts:

  • Stale: the cached copy might no longer match the server.
  • While: keep that copy visible while another operation happens.
  • Revalidate: ask for current data and reconcile the cached copy with the result.

Think of a weather app. It can immediately show the last known temperature instead of replacing the screen with a spinner. It then requests the latest weather and updates the display. If that request fails, it can keep the last known temperature visible with a warning.

SWR is useful when multiple components need shared server data with a compact API. It manages cached reads, subscriptions, request deduplication, and revalidation. It also supports mutations and pagination; "small API" does not mean "reads only."

It does not replace fetch, your backend, or a UI state store. You supply the function that loads data, and SWR coordinates its lifecycle.

The strategy, the HTTP directive, and the library are different

Term Where it operates What it means
Stale-while-revalidate strategy General caching design Reuse an old answer while checking for a new one
HTTP Cache-Control: stale-while-revalidate HTTP caches such as browsers and CDNs Allow an HTTP cache to serve a stale response for a bounded window while revalidating it
SWR library React application Share keyed data and coordinate fetching, subscriptions, and refresh triggers

Using SWR does not automatically add HTTP cache headers. An SWR revalidation calls your fetcher; the browser or CDN might still satisfy that request from its own cache according to HTTP rules. Conversely, an HTTP cache directive does not synchronize React components for you.

See Browser Caching for the HTTP layer.

What happens from the first render to a later refresh?

Suppose two components display the price for the same product, region, and currency:

  1. The first component calls useSWR with the price's key.
  2. With no cached result, SWR starts the fetcher and exposes a loading state.
  3. The second component uses the same key in the same cache scope. SWR can share the request instead of issuing a duplicate.
  4. On success, SWR stores the result and notifies the components using that key.
  5. Later, a configured trigger such as window focus starts revalidation. Existing data remains visible.
  6. On success, SWR publishes the current result. On failure, it exposes an error without requiring useful cached data to disappear.

For example:

First visit:        no cached price -> loading -> server returns INR 500
Return to tab:      show INR 500 immediately + check server in background
Refresh succeeds:   server returns INR 520 -> subscribers show INR 520
Next refresh fails: keep INR 520 + show "price may be outdated"

This is a lifecycle example, not a guarantee of one request per mount or focus event. Deduplication, focus throttling, and configured revalidation options affect whether a trigger starts another request.

Reading the hook API

const { data, error, isLoading, isValidating, mutate } =
  useSWR(key, fetcher, options);
Part Meaning
key Identity of the data being requested; null disables fetching
fetcher Promise-returning function that receives the key and loads the data
options Policies for revalidation, retries, deduplication, and other behavior
data Current returned data for this hook, or undefined when unavailable
error Error thrown by the fetcher, when present
isLoading A request is running without loaded data for the current key
isValidating A request is running, including a background revalidation
mutate Function bound to this key for updating its cached data and/or revalidating

isLoading and isValidating are not interchangeable. The initial request normally makes both true. A background refresh with loaded data normally makes only isValidating true. With fallbackData or keepPreviousData, the hook can display data while isLoading is still true: SWR does not count fallback or previous-key data as loaded data for the current request.

Also, data and error can exist together after a failed refresh. Rendering only if (error) return <Error /> would hide the cached content unnecessarily.

A complete read example

import useSWR from 'swr';
import { getJson } from './http';

export function ProductPrice({ productId, region, currency }) {
  const params = new URLSearchParams({ region, currency });
  const url =
    `/api/products/${encodeURIComponent(productId)}?${params}`;

  const { data, error, isValidating, mutate } = useSWR(url, getJson, {
    dedupingInterval: 2_000,
    revalidateOnFocus: true,
    shouldRetryOnError: false,
  });

  if (data === undefined) {
    if (error) return <p role="alert">Could not load the product.</p>;
    return <p>Loading product...</p>;
  }

  return (
    <section>
      <p>{data.priceCents / 100} {data.currency}</p>
      {isValidating && <small>Checking the latest price...</small>}
      {error && <p role="alert">Refresh failed. Showing cached data.</p>}
      <button
        type="button"
        disabled={isValidating}
        onClick={() => {
          void mutate();
        }}
      >
        Refresh price
      </button>
    </section>
  );
}

The URL is the key, and SWR passes it to getJson. The helper from Section 1 throws on HTTP errors, so a 500 becomes an error rather than successful cached JSON. The bound mutate() asks SWR to revalidate this key; fetch failures are exposed through the hook's error state.

Keys, arguments, and conditional fetching

SWR caches by key, not by component name or fetcher function name. Every input that changes the returned representation must participate in that identity.

The read example includes product, region, and currency in the URL. An array key is another option:

const key = productId && region && currency
  ? ['product', productId, region, currency]
  : null;

const query = useSWR(
  key,
  ([, id, selectedRegion, selectedCurrency]) => {
    const params = new URLSearchParams({
      region: selectedRegion,
      currency: selectedCurrency,
    });
    return getJson(
      `/api/products/${encodeURIComponent(id)}?${params}`
    );
  }
);

In SWR v2, the fetcher receives the array as one argument; it is not spread into separate arguments. Equivalent serializable array/object key contents can identify the same resource even when a new array/object is created during render.

With null, SWR does not start the request. Render an explicit "Select a product and pricing context" state while these inputs are missing, rather than treating disabled fetching as an endless load.

If the response also depends on the signed-in account or tenant, isolate the cache appropriately and include the relevant non-secret identity where needed. A key is not an authorization mechanism, and keys should not expose credentials.

Shared configuration and cache scope

The default cache works without an explicit provider in a simple browser app. Use SWRConfig to share policies, and its provider option when you need a separate cache boundary:

import { SWRConfig } from 'swr';
import { getJson } from './http';
import { ProductPrice } from './ProductPrice';

const swrConfig = {
  provider: () => new Map(),
  fetcher: getJson,
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
  shouldRetryOnError: false,
};

export function App() {
  return (
    <SWRConfig value={swrConfig}>
      <ProductPrice productId="P100" region="IN" currency="INR" />
      <ProductPrice productId="P100" region="IN" currency="INR" />
    </SWRConfig>
  );
}

Both components use the same provider's cache. Adding SWRConfig just for options does not itself create an isolated cache; the custom provider does. Keep the boundary stable: remounting it recreates this cache. This is a browser example, not permission to share private data across server requests.

Inside a custom provider boundary, use the hook's bound mutate or obtain scoped mutate with useSWRConfig(). The globally imported mutate does not update hooks under that custom cache provider.

What does dedupingInterval mean?

SWR coordinates requests for the same key, with a configurable deduplication interval. The documented default is 2,000 ms.

It is not a promise that server data stays fresh for two seconds, not a cache expiry timer, and not a polling interval. After the interval passes, SWR does not automatically delete the cached result or start a request just because time elapsed. A relevant trigger is still needed. Explicit mutation/revalidation paths have their own behavior; do not treat the interval as a universal rate limit.

Which options control refresh behavior?

Option SWR v2 default Purpose
revalidateIfStale true Normally revalidate cached data on mount, subject to mount settings
revalidateOnMount Unset Explicitly control whether mounting triggers revalidation
revalidateOnFocus true Check again when the window regains focus, subject to throttling/deduplication
revalidateOnReconnect true Check again when the browser regains connectivity
refreshInterval 0 Enable polling when set to a positive interval in milliseconds
dedupingInterval 2000 Coordinate same-key requests within the deduplication window
shouldRetryOnError true Allow automatic retries after fetcher errors

Polling normally pauses when the page is hidden or the browser is offline unless the corresponding options enable it. SWR does not have TanStack Query's staleTime freshness-window model; do not translate staleTime: 30_000 into dedupingInterval: 30_000 and assume equivalent behavior.

The example disables automatic retries to make the error policy explicit. A real application can enable bounded retries for selected failures rather than treating every 401, 404, and network error alike.

What about writes and optimistic updates?

useSWR normally starts a read automatically. A write should happen in response to an explicit action, not merely because the component rendered.

API / option Role
Bound mutate() Revalidate the mounted hook's current key
Bound mutate(confirmedData, { revalidate: false }) Replace that key's cached result with an authoritative response without an extra read
Scoped mutate(key) Request revalidation for a key used by mounted hooks in the same cache scope
useSWRMutation from swr/mutation Run an explicit remote operation through trigger(arg) and expose isMutating, data, and errors
optimisticData Show a proposed cache value before the server confirms the write
rollbackOnError Restore the previous cached value when the optimistic write fails
populateCache Control whether/how the remote operation's result updates the read cache

Calling mutate with a plain object does not send a PATCH request. It changes the client cache. To change the server, supply an async write operation to mutate, use useSWRMutation with a write fetcher, or make the write explicitly and then reconcile the cache. Handle write failures visibly; a local optimistic value is not proof of a server commit.

Updating a detail key does not automatically update every list or aggregate containing that product. SWR is not an entity-normalized cache. Reconcile affected keys deliberately, as discussed in Mutations and Cache Invalidation.

When is SWR a good fit?

  • Good fit: dashboards, profile screens, and shared reads that benefit from cached content plus background revalidation.
  • Also supported: paginated/infinite reads through useSWRInfinite from swr/infinite, mutations, and framework-specific server-provided fallback data.
  • Consider alternatives: TanStack Query when its query/mutation workflow controls better match the team; Apollo when GraphQL entity normalization is central.

Common mistakes are using incomplete keys, creating an isolated cache per component, treating the deduplication window as freshness or polling, hiding cached data on every refresh, and assuming cache updates change the server.

Back to Top

9. Apollo Client

What is it, and why use it?

Apollo Client is a GraphQL client with a normalized cache. It can identify shared entities across different query results.

TanStack Query and SWR primarily associate results with query keys. Apollo can also associate returned objects with entity identities such as Product:P100.

Query cache vs entity cache

flowchart LR
    A["Product details query"] --> E["Product:P100"]
    B["Recommendation query"] --> E
    E --> N["name: System Design Book"]
    E --> P["priceCents: 5000"]

If both query results refer to the same normalized entity, writing updated fields for that entity can update views that read those fields.

This does not automatically fix every list, filter, or aggregate. Inserting a product into a search result, removing it from a category, and updating a cart total may need explicit cache work or refetching.

Set up Apollo Client v4

import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
import { ApolloProvider } from '@apollo/client/react';
import { ProductPrice } from './ProductPrice';

const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});

export function App() {
  return (
    <ApolloProvider client={client}>
      <ProductPrice productId="P100" />
    </ApolloProvider>
  );
}

As with the TanStack example, this is browser setup, not a process global private cache for server rendering.

Read the product

import { gql } from '@apollo/client';
import { useQuery } from '@apollo/client/react';

const GET_PRODUCT = gql`
  query GetProduct($id: ID!) {
    product(id: $id) {
      id
      name
      priceCents
      currency
    }
  }
`;

export function ProductPrice({ productId }) {
  const { data, loading, error } = useQuery(GET_PRODUCT, {
    variables: { id: productId },
    fetchPolicy: 'cache-and-network',
    errorPolicy: 'none',
    notifyOnNetworkStatusChange: true,
  });

  if (data === undefined) {
    if (error) return <p role="alert">Could not load the product.</p>;
    return <p>Loading product...</p>;
  }

  if (!data.product) return <p>Product not found.</p>;

  return (
    <section>
      <p>{data.product.priceCents / 100} {data.product.currency}</p>
      {loading && <small>Checking the latest price...</small>}
      {error && <p role="alert">Refresh failed. Data may be outdated.</p>}
    </section>
  );
}

This schema returns a product in one fixed pricing context. If pricing changes by region, model it explicitly: use region arguments on a price field, a region-specific price entity, or appropriate cache policies.

Merely adding region to the root query variables does not ensure that a scalar Product.priceCents is stored separately by region on the same normalized Product:P100.

Important Apollo concepts

Concept Meaning
cache-first Use a complete cached result when available, otherwise fetch
cache-and-network Use available cached data and also request current data
network-only Fetch from the network and normally write results into the cache
no-cache Fetch without storing that result in the normalized cache
typePolicies / keyFields Define how entities and fields are identified and merged
Error policy Decide how GraphQL errors and partial data are exposed

GraphQL can return HTTP 200 with errors in its response body. Inspect Apollo's error/data contract rather than relying only on HTTP status. Use partial data only when your UI is designed for missing fields.

Deduplication is not normalization

Apollo can deduplicate identical queries while they are in flight. Matching query text, operation name, and variables is distinct from merging entity fields after a response.

Do not assume authorization headers automatically create a different deduplication identity. Avoid sharing a client across users or incompatible authorization contexts. Disabling query deduplication alone does not fix normalized cache identity or tenant isolation.

no-cache is a result storage policy, not a guarantee that identical pending queries cannot share transport work.

Back to Top

10. Compare the Three Libraries

Question TanStack Query SWR Apollo Client
Main mental model Queries identified by keys Keyed data plus revalidation GraphQL operations plus normalized entities
Transport Any promise based query function Any promise based fetcher GraphQL focused
REST use Common Common Usually not the reason to choose it
GraphQL use Via a query function Via a fetcher Central use case
Shared reads Same query/client scope Same key/cache scope Matching operations/client scope
Cache updates after writes Update keyed results or invalidate mutate and revalidation Entity writes, field policies, cache updates, or refetch
Entity normalization Not automatic by default Not automatic by default Built into InMemoryCache when identities are available
Typical starting point Explicit async workflow control Compact shared fetching hooks GraphQL app benefiting from entity relationships

All three can support serious applications. Choose based on your API shape, cache model, framework integration, and team experience, not a claim that one is universally fastest.

Do not add two independent owners for the same resource accidentally. If both SWR and TanStack Query cache the same cart, changing one will not automatically update the other.

Back to Top

11. Freshness and Background Refresh

What does stale mean?

Stale means eligible to be checked again, not "definitely incorrect" and not "must disappear."

A price fetched one minute ago may still be correct. A price fetched one second ago may already have changed. Freshness policies are client assumptions about acceptable reuse, not server guarantees.

sequenceDiagram
    participant UI as Product page
    participant C as Cache
    participant API as API
    UI->>C: Read product
    C-->>UI: Cached product available
    UI->>UI: Render immediately
    C->>API: Revalidate on configured trigger
    API-->>C: Current product
    C-->>UI: Publish refreshed result

Useful refresh triggers include returning to the page, reconnecting, explicit invalidation after a mutation, polling, or receiving a server event.

Choose freshness by the data

Data Example policy
Country names Long reuse window
Product description Moderate reuse and invalidate after edits
Order progress Refresh on events or controlled polling
Inventory display Short reuse, but verify again when reserving
Final checkout total Server recalculates before committing the order

Displaying cached availability is a UX decision. It does not reserve inventory. See Optimistic vs Pessimistic Locking for the authoritative write boundary.

Back to Top

12. Loading, Empty, and Error States

Do not reduce every request to "spinner or data."

State What the user should see
First load, no result Loading indicator or skeleton
Successful empty list An intentional "No products found" state
Cached result refreshing Keep content visible with a small refresh indicator
First request failed Error message and a deliberate retry action
Refresh failed but old data exists Keep useful data and warn that it may be outdated
Request intentionally disabled A waiting/input state, not an endless loading spinner
Offline with no cached data Explain that data cannot currently be loaded

Different libraries have different flag semantics. For example, TanStack's pending state is not identical to "an HTTP request is currently running"; SWR separates isLoading from isValidating.

The earlier REST examples assume complete inputs and immediately enabled queries. If you add disabling or dependent fetching, handle that waiting state explicitly.

Back to Top

13. Mutations and Cache Invalidation

What is a mutation?

A mutation is a command that changes server state: save a product, add an item, or create a reservation.

After a write, ask which reads became outdated.

flowchart TD
    A["User saves product"] --> B["API validates and commits"]
    B --> C["Return confirmed result"]
    C --> D["Update exact cached product when safe"]
    C --> E["Invalidate affected lists and totals"]
    E --> F["Active views refresh"]

For TanStack Query, after the server confirms an edit:

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { HttpError } from './http';

export function useRenameProduct(productId) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async name => {
      const response = await fetch(
        `/api/products/${encodeURIComponent(productId)}`,
        {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ name }),
        }
      );
      if (!response.ok) throw new HttpError(response.status);
      return response.json();
    },
    onSuccess: async () => {
      await Promise.all([
        queryClient.invalidateQueries({
          queryKey: ['product'],
          predicate: query =>
            query.queryKey[1]?.productId === productId,
        }),
        queryClient.invalidateQueries({ queryKey: ['products'] }),
      ]);
    },
  });
}

This assumes every ['product', ...] key follows Section 7's object shape. It invalidates cached region/currency variants for that product and product lists. The caller must render mutation pending/error states; the hook does not swallow failures.

The endpoint illustrates cache invalidation, not complete concurrency protection. For a real editing API, carry the expected version or If-Match validator as described in the locking guide.

Equivalent intent in the other libraries

  • SWR: use the relevant bound/scoped mutate or mutation API to update data and/or revalidate keys.
  • Apollo: return entity IDs and changed fields, then update affected lists/fields or use refetchQueries where needed.

Normalization helps propagate fields on existing entities. It does not infer every business relationship or automatically know every filtered list affected by a write.

What about optimistic UI?

Show a predicted change immediately only when it is safe and recoverable. Keep confirmed data and pending operations conceptually separate.

If two additions overlap, restoring one old snapshot after a failure can erase another successful addition. Coordinate optimistic layers, serialize dependent commands, or refetch and reconcile.

Cache invalidation does not undo a server write, and a background refetch is not a database lock.

Back to Top

14. Cancellation, Races, and Retries

Old responses must not replace new intent

The user searches for book, then bookshelf. The first request is slower:

Request A: book       starts first, finishes last
Request B: bookshelf  starts second, finishes first

With one unguarded local results setter, A may overwrite B.

Use distinct keys containing the search term. The visible component subscribes to the current key, so A's response belongs to A's cache entry rather than B's view.

Cancellation can save resources too. TanStack passes an AbortSignal to the query function; pass it through to fetch as in Section 7. Actual transport cancellation depends on consuming that signal and on the observer lifecycle.

Do not assume SWR automatically cancels every previous fetch; cancellation integration depends on your fetcher and coordination. Do not add a per-component abort that kills a shared request another subscriber still needs.

Aborting a browser request does not prove the server stopped processing a command. Mutation recovery still needs operation identity and server state.

Retry intentionally

  • Retry transient read failures within a bounded budget.
  • Do not endlessly retry authentication failures or invalid input.
  • Respect rate limits and Retry-After when the API supplies them.
  • Use increasing delay and jitter to avoid synchronized retry storms.
  • Avoid retrying at the component, library, HTTP client, and gateway simultaneously without a shared budget.
  • For mutations, determine whether repeating the command is safe before enabling retries.

The TanStack example's retry policy is illustrative: it permits limited non-HTTP failures and 5xx, but not 4xx. Production code should distinguish malformed data, cancellation, and genuinely transient network failures, and may handle 429 specially.

Back to Top

15. Parallel Requests, Pagination, and Prefetching

Remove accidental waterfalls

Product details and public reviews may be independent:

Unnecessary waterfall:
Product request finishes -> then reviews request starts

Parallel:
Product request starts + reviews request starts

Start independent reads together. Keep a dependency when it is real: fetching a user's order requires knowing the authenticated user or order identifier.

Framework route loaders or server fetching can start work before individual components mount. A client library does not automatically remove a waterfall created by your component tree.

Pagination

Include filters and cursor/page in the identity. Do not store every page under ['products'].

Use the library's pagination facilities:

  • TanStack Query: useInfiniteQuery or keyed page queries.
  • SWR: useSWRInfinite.
  • Apollo: fetchMore and appropriate field merge/key policies.

Reset or separate page collections when the filter changes. Bound retained pages for long sessions and large responses.

See Pagination Patterns and Virtualization. Fetching fewer pages and rendering fewer DOM nodes solve different problems.

Prefetch with a purpose

Prefetch the next page or likely destination when it is valuable. It must use the same identity as the later consumer.

Prefetching every product on a catalog page can waste bandwidth and compete with the data needed now. Consider navigation likelihood, connection quality, and freshness.

Back to Top

16. Authentication, Server Rendering, and Cache Boundaries

One client is not one universal safe cache

Sharing is valuable only inside the correct privacy boundary.

Boundary Rule
Components in one browser session Share the intended provider/client
Different tenants or signed in accounts Partition identity and clear/reset private state on switching
Server rendering Use a request isolated client/cache for private data
Persisted cache Define retention, migration, and deletion on logout
Different tabs Ordinary in-memory caches are separate

On logout, coordinate cancellation of old authenticated work, credential changes, and clearing/resetting private caches. Clearing data alone is insufficient if an old request later refills the cache.

With Apollo, resetStore() can refetch active queries while clearStore() clears without that refetch. Choose intentionally and coordinate authentication transitions rather than blindly refetching with old credentials.

Hydration

Server rendering can fetch data before sending HTML, then transfer a safe cache snapshot to the browser.

The client must use compatible keys/policies to reuse that snapshot. Otherwise it can immediately repeat the request.

Never serialize secrets or data the page's user is not authorized to see. A module global private cache in a shared server process can leak data between requests.

The examples in this article use browser providers. Follow your framework's integration for server components, route loaders, streaming, and hydration instead of mounting another cache layer without a reason.

Cross tab updates

After one tab changes a cart, other tabs do not automatically receive its cache invalidation. A local message or server event can tell them to invalidate their own queries.

See Cross Tab Communication. Keep the backend authoritative; missed notifications must be recoverable through normal revalidation.

Back to Top

17. A Practical Decision Guide

flowchart TD
    A["What does the application need?"] --> B{"GraphQL entity relationships are central?"}
    B -->|"Yes"| C["Consider Apollo Client"]
    B -->|"No"| D{"Explicit async workflows and query controls?"}
    D -->|"Yes"| E["Consider TanStack Query"]
    D -->|"No"| F{"Compact shared reads and revalidation?"}
    F -->|"Yes"| G["Consider SWR"]
    F -->|"No"| H["Use framework fetching or a small explicit request flow"]

Use this as a starting point, not a hard boundary. TanStack Query and SWR can fetch GraphQL, Apollo supports advanced workflows, and framework fetching may already solve much of your use case.

Questions to ask before choosing

  1. Is the server API REST, GraphQL, or mixed?
  2. Do we benefit from normalized entities or primarily keyed responses?
  3. Which framework already owns loading and server rendering?
  4. What are the privacy and tenant boundaries?
  5. How do writes update lists, totals, and detail screens?
  6. What freshness and offline behavior do users need?

Do not build a general query framework just to share one request. Also do not scatter dozens of independent fetch effects and assume a global UI store will supply query lifecycle management automatically.

Back to Top

18. Testing and Observability

Test behavior, not just the happy response

Test Required outcome
Three overlapping consumers use one identity and cache scope One shared initial load under the chosen policy
Region, filter, or account changes No reuse of an incompatible result
Request fails Explicit error; no empty success fallback
Failed shared request is requested again A later attempt is possible
Cached data refresh fails Useful data remains visible with a warning
Search results arrive out of order Old search cannot replace current results
One subscriber unmounts Remaining subscribers still receive appropriate results
A mutation succeeds Relevant details, lists, and totals reconcile
A mutation response is lost UI handles unknown outcome without blind duplication
Logout happens during a request Old private data cannot refill the next user's cache
Two server requests belong to different users No shared private cache or promise
Pagination filter changes Old pages are not appended to the new collection

Control mount, focus, retry, and revalidation behavior in the test. "Exactly one request forever" is not the correct assertion.

For the minimal helper, hold a mocked loader pending and invoke it twice with the same key. Assert one loader invocation and the same promise. Settle it, call again, and assert a new invocation. Repeat with a rejected load and a synchronous exception.

Useful metrics

  • Initial load and background refresh latency.
  • Requests per logical query identity.
  • Cache reuse and request sharing.
  • Error and retry counts by status/category.
  • Mutation reconciliation failures.
  • Cache size and retained pagination data.
  • Time to useful content rather than only time to first HTTP response.

Use development tools to inspect cache keys and active observers, but do not log tokens, private payloads, or search text indiscriminately.

Back to Top

19. Interview Questions and Answers

Why not just use fetch?

You can for a simple request. A library becomes useful when multiple consumers need shared data, freshness, background refresh, mutation coordination, and lifecycle management.

What is request deduplication?

Equivalent consumers share pending work instead of independently starting the same request within the library's sharing scope and policy.

Is deduplication the same as caching?

No. Deduplication shares a request that is running; result caching reuses an answer that has already arrived. A library can implement both.

Does stale data mean wrong data?

No. It means the client's policy allows checking it again. The server may or may not have changed.

What is SWR, in plain English?

SWR is a React library for shared data fetching. Its name means stale-while-revalidate: show a cached answer immediately, then check for an updated answer on configured triggers. You supply the fetcher; SWR manages keyed caching, request sharing, subscriptions, and revalidation.

Is SWR's dedupingInterval the same as TanStack Query's staleTime?

No. dedupingInterval coordinates same-key requests in a time window. staleTime determines how long TanStack Query considers data fresh. Neither is a polling interval, and neither means the server cannot have changed.

Does SWR require HTTP stale-while-revalidate headers?

No. The React library and the HTTP cache directive operate at different layers. They can be used together, but configuring one does not automatically configure the other.

What is the main Apollo difference?

Its GraphQL normalized cache can share identified entities across query results. That is different from storing each query result only under its query key.

Does cache invalidation guarantee current stock?

No. It helps refresh the display. The server must validate stock and enforce the invariant when committing the business operation.

Will request deduplication prevent two payments?

No. A payment needs server-side operation identity and idempotency. Frontend sharing is not a business transaction guarantee.

Can I use GraphQL with TanStack Query or SWR?

Yes. Their fetchers can call a GraphQL transport, but entity normalization is not automatically supplied just because the transport is GraphQL.

Back to Top

20. Summary and Further Reading

Explain the design in plain English

I give each server representation a correct identity and share its loading work within the right client scope. I reuse cached data when appropriate, refresh it deliberately, and keep useful content visible during background failures. After a write, I reconcile affected views. The server still owns authorization, concurrency, and business correctness.

Remember:

  1. A request key is a data identity, not just a convenient label.
  2. Caching, deduplication, batching, and idempotency solve different problems.
  3. TanStack Query emphasizes keyed async workflows, SWR emphasizes compact revalidation hooks, and Apollo emphasizes GraphQL operations and normalized entities.
  4. Stale data can remain useful, but final business decisions need authoritative server checks.
  5. Private caches must respect account, tenant, and server request boundaries.

Official documentation

Related articles

Back to Top

More Details:

Get all articles related to system design
Hastag: SystemDesignWithZeeshanAli

systemdesignwithzeeshanali

Git: https://github.com/ZeeshanAli-0704/front-end-system-design

โฌ† Back to Top

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.