Slow pages often trace back to how the app fetches data. A request that waits for another request, the same call repeated on every render, or a spinner that never gives way to an error message all hurt users and conversion. These patterns fix the most common problems.
Fetch on the server when you can
A server component or a server-side loader sits close to your database and APIs, so requests complete in milliseconds and no data-fetching code ships to the browser. The page arrives with its content in the HTML, which helps both users and search engines.
// Next.js server component
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id);
return <ProductView product={product} />;
}
Reserve client-side fetching for data that depends on user interaction, such as search-as-you-type, infinite scroll or live dashboards.
Avoid request waterfalls
A waterfall happens when request B waits for request A even though B does not need A's result. Run independent requests in parallel:
const [user, orders, recommendations] = await Promise.all([
getUser(id),
getOrders(id),
getRecommendations(id),
]);
When one request depends on another, start the dependent work as early as you can, or move the join to the backend so one round trip returns the combined result. In React, wrap slower sections in Suspense so the fast parts of the page render first.
Use a client cache for client fetching
Libraries such as TanStack Query and SWR handle caching, deduplication, retries and background refresh. Without them, each component calls fetch in useEffect, and you rebuild those features badly.
const { data, isPending, error } = useQuery({
queryKey: ["orders", userId],
queryFn: () => fetchOrders(userId),
staleTime: 60_000,
});
The queryKey identifies the data. Two components that request the same key share one network call. staleTime says how long the cached result counts as fresh, which prevents refetching on every mount.
Choose a cache lifetime per data type
- Static content such as marketing pages: cache for hours or build time, and revalidate on publish.
- Reference data such as categories: cache for minutes.
- User-specific data such as a profile: cache and invalidate after edits.
- Live data such as stock levels: use short intervals, server-sent events or WebSockets.
Set HTTP cache headers and use stale-while-revalidate so a CDN serves a cached copy while it refreshes in the background.
Handle mutations with optimistic updates
After a user clicks "like", update the interface and send the request in the background. If it fails, roll back and show a message. TanStack Query's onMutate and onError callbacks support this pattern. Always invalidate related queries on success so the cache matches the server.
Design loading and error states
- Show skeleton screens that match the final layout to prevent layout shift.
- Keep previous data on screen while new data loads, for example when paging through a table.
- Give every failure a message and a retry button. A blank screen leaves users guessing.
- Set request timeouts. A call with no timeout can hang the interface for minutes.
Reduce payload size
Request only the fields you need. GraphQL does this by design; with REST, add field selection or build view-specific endpoints. Paginate long lists with cursors, compress responses with Brotli or gzip, and prefetch the next page when the user nears the end of the current one.
Validate responses
An API can change or return an unexpected shape. Parse responses with a schema library like Zod so a bad payload triggers a clear error at the boundary instead of a crash deep in a component.
Checklist for your next feature
- Can the server fetch this data before rendering?
- Do any requests run one after another without need?
- Is there a cache with a stated lifetime?
- Do loading, empty and error states exist?
- Does a mutation update or invalidate the affected data?
Common questions
Should I use fetch, Axios or a GraphQL client?
The built-in fetch covers most needs and works on the server and in the browser. Axios adds interceptors and automatic JSON handling, which some teams like. A GraphQL client such as Apollo or urql suits GraphQL APIs. Whichever you choose, wrap it in one small API module so the rest of the app never depends on the library directly.
How do I handle authentication tokens in requests?
Keep the logic in one place. A shared request function attaches the token, retries once after a refresh when the server returns 401, and signs the user out if the refresh fails. Scattering this logic across components leads to inconsistent behavior and hard-to-find bugs.
When does polling beat WebSockets?
Polling every 15 to 60 seconds is simple and enough for dashboards that tolerate slight delay. Use WebSockets or server-sent events for chat, live prices and collaborative editing, where updates must appear within a second or two. Server-sent events are the easier choice when data flows only from server to client.
How do I test data fetching code?
Mock the network at the boundary with Mock Service Worker, which intercepts requests in both tests and the browser. Test the real states users see: loading, success, empty list, server error and offline. Tests written against these states survive refactors of the fetching library.
Our team tunes data layers for e-commerce and SaaS apps, often cutting load times by half. Contact us for a performance review or browse our recent projects.

