Why a page makes so many requests
A modern single-page application can easily send hundreds of requests per page view. On my own Twitter home page, for example, the browser fires around 300 requests; an Amazon product page can push that past 600. Static assets account for some of that traffic, but often a hundred or more requests are pure async data fetching — timelines, friend lists, recommendations, analytics events.
The goal is not bandwidth consumption but perceived performance. Users no longer wait five seconds for a blank page. They expect a styled, interactive shell within a second, with remaining content filled in progressively. An Amazon product page illustrates the pattern: navigation and the top bar render first, then product images and description, then sponsored content, ratings and recommendations as the user scrolls. None of these sections are equally critical, and splitting them across separate requests lets the browser paint the important parts first.
Careful splitting goes a long way, but large applications face deeper problems. Async logic does not fit a linear mental model; network calls can fail for many reasons; and hidden concerns such as data format, security, cache behavior and token expiry complicate every request. Front-end data fetching therefore needs deliberate patterns.
The example: a profile screen
The following discussion uses a Profile screen that loads a user’s brief (name, avatar, short description) and their list of connections. The brief comes from /users/<id>, returning a simple object:
{
"id": "u1",
"name": "Juntao Qiu",
"bio": "Developer, Educator, Author",
"interests": [
"Technology",
"Outdoors",
"Travel"
]
}
Connections come from a separate /users/<id>/friends endpoint that returns a list of identical user objects. The two endpoints exist separately because friend counts vary wildly — one user may have a thousand, most have few — which matters when the list needs pagination. The point is that real interfaces frequently require coordinating multiple network requests.
Although the examples below are written in React, the patterns apply to any front-end framework. React is used purely for illustration.
Asynchronous state handler
The first pattern treats each query not as a one-off call but as a state machine. Fetching passes through a defined lifecycle with metadata describing its current phase: loading, error, success, or idle. Wrapping the raw request with that metadata is the essential job of this handler.
A minimal version exposes fields such as status, data and error through a hook or a higher-order component, and a component can then branch on those fields to render a loading indicator, an error message or the content. Centralizing that logic in one place keeps UI components free of request life-cycle details and makes failure states consistent.
Fallback markup
Rather than letting the UI default to a blank state while a fetch is in flight, the next pattern declares fallback content directly in the markup. When parts of the screen are not yet available, framework-agnostic constructs — React’s Suspense, for instance — allow the developer to specify what to display. This makes the loading behavior explicit at the component level and is easier to maintain than scattering conditional checks through the render path.
Escaping the request waterfall
A common bug in async rendering is a request waterfall: component A waits for a user fetch, then triggers a friends fetch only after that first call resolves, even though the two requests are independent. Sequencing them this way adds unnecessary latency.
Parallel data fetching
Independent calls should fire at the same time. In JavaScript, Promise.all is the simplest way to run multiple requests concurrently. Waiting on the combined promise avoids the waterfall and minimizes total wait time, although the caller must wait for the slowest request.
The pattern becomes more nuanced when the UI can render as soon as some — not all — requests complete. Promise.all then fails the entire batch when a single request rejects, which may be too aggressive. Alternatives such as starting the requests together and resolving with the first result (Promise.race) suit cases where identical data can come from several endpoints.
Code splitting
Large bundles penalize every user, even those who never touch most of the code. Code splitting defers loading of non-critical application parts until they are needed. Lazy-loaded modules or route-level splitting shrink the initial JavaScript payload and move requests out of the critical rendering path.
Prefetching
Latency can also be hidden by fetching before the user asks. Prefetching collects data that will likely be needed soon — on hover of a link, on focus of an input, or when a route becomes likely — and caches it so that the eventual page transition feels instant. This pattern requires judgment about which interactions signal an upcoming request, but done well it turns perceived performance up without complicating the code that actually renders the data.
React Foundations: Components, JSX, and Hooks
React components are the core building blocks of a React application. A component is simply a function that returns a piece of UI, often written in JSX—a JavaScript syntax extension that mixes markup with logic. For TypeScript users, the equivalent is TSX. Since browsers don't understand JSX natively, a compiler (such as Babel) translates it into standard JavaScript.
function Navigation() {
return React.createElement(
"nav",
null,
React.createElement(
"ol",
null,
React.createElement("li", null, "Home"),
React.createElement("li", null, "Blogs"),
React.createElement("li", null, "Books")
)
);
}
The compilation process relies on React.createElement, a foundational function that constructs the element tree. Its basic signature accepts a type (a string for DOM tags or a reference to a component), a props object (containing attributes like className and event handlers), and optional children arguments—which can be other element calls, strings, numbers, or any combination. Internally, React uses the native DOM API to materialize these elements.
React.createElement(type, [props], [...children])
Components assemble into a tree structure and mount to a root node in the application. What makes JSX powerful is its ability to generate content dynamically. A component can accept parameters, known as props, and use them to shape its output:
import React from 'react';
function Navigation({ nav }) {
return (
<nav>
<ol>
{nav.map(item => <li key={item}>{item}</li>)}
</ol>
</nav>
);
}
Curly braces {} in JSX signal that the enclosed JavaScript expression should be evaluated. This pattern allows passing diverse data into a component—like arrays of books, or flags controlling whether to display only new releases—precisely as one would pass arguments into a function. One distinction: components invoked via JSX syntax read more like HTML, which frontend developers find familiar.
Persisting State with useState
Interfaces frequently need to react to user interactions—clicking an "Add" button should update a shopping cart's total and inventory. A common mistake is attempting to modify a local variable inside a component function to achieve this:
function App () {
let showNewOnly = false;
const handleCheckboxChange = () => {
showNewOnly = true; // this doesn't work
};
const filteredBooks = showNewOnly
? booksData.filter(book => book.isNewPublished)
: booksData;
return (
<div>
<Checkbox checked={showNewOnly} onChange={handleCheckboxChange}>
Show New Published Books Only
</Checkbox>
<BookList books={filteredBooks}/>
</div>
);
};
This fails because local variables don't preserve their values between renders. Each re-render starts from scratch, and React remains unaware of changes unless explicitly told to re-render. This is where state enters the picture. The useState hook lets a functional component "remember" values across renders:
import React, { useState } from 'react';
import Checkbox from './Checkbox';
import BookList from './BookList';
function App () {
const [showNewOnly, setShowNewOnly] = useState(false);
const handleCheckboxChange = () => {
setShowNewOnly(!showNewOnly);
};
const filteredBooks = showNewOnly
? booksData.filter(book => book.isNewPublished)
: booksData;
return (
<div>
<Checkbox checked={showNewOnly} onChange={handleCheckboxChange}>
Show New Published Books Only
</Checkbox>
<BookList books={filteredBooks}/>
</div>
);
};
The hook's syntax is straightforward:
const [state, setState] = useState(initialState);
useState takes an initialState argument—only applied during the first render—and returns an array of two elements. By convention, these are destructured as state (the current value) and setState (the update function). Calling setState doesn't mutate the existing variable; rather, it schedules a re-render with a new state snapshot. React then acknowledges the updated state, ensuring downstream components receive the corrected data.
Handling Side Effects with useEffect
React's rendering model focuses on painting the DOM; it doesn't inherently manage external operations like data fetching or direct DOM manipulation. For these "side effects," React provides the useEffect hook, which runs after the rendering cycle has completed, and if these effects adjust data, React schedules further re-renders accordingly.
The hook accepts two arguments: a function containing the effect logic, and an optional dependency array. Omitting the array runs the effect after every render; providing an empty array [] runs it only once (assuming no prop or state changes); and including specific values re-runs the effect only when those values change.
Typically, asynchronous data fetching within useEffect involves initiating a request, capturing the response, and storing it via useState. A standard pattern for this:
import { useEffect, useState } from "react";
type User = {
id: string;
name: string;
};
const UserSection = ({ id }) => {
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/users/${id}`);
const jsonData = await response.json();
setUser(jsonData);
};
fetchUser();
}, [id]);
return <div>
<h2>{user?.name}</h2>
</div>;
};
Because useEffect doesn't accept an async function directly as its callback, an async function like fetchUser is declared inside and then immediately invoked. This allows using await for the network operation before updating state with setUser. The dependency array [id] restricts re-execution to when the id prop changes, thereby avoiding redundant network requests on every render. Beyond this core architecture, production applications also track distinct component states—loading, error, and data—to deliver clear feedback to users throughout a fetch lifecycle.
With these wiring concepts—components, props, useState, and useEffect—in place, the mechanics of various data fetching architectures become much clearer.
Managing Loading and Error States
A typical Profile component fetches data inside a useEffect block, but that naive approach assumes network requests complete instantly. Real-world conditions require handling delays and failures, so the component needs to track loading and error states alongside the fetched data. The enhanced implementation uses useState to hold those three pieces of state, then triggers the request in useEffect, toggling the loading flag and writing either the user object or an error.
import { useEffect, useState } from "react";
const Profile = ({ id }: { id: string }) => {
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
const fetchUser = async () => {
const response = await fetch(`/api/users/${id}`);
const jsonData = await response.json();
setUser(jsonData);
};
fetchUser();
}, [id]);
return (
<UserBrief user={user} />
);
};
import { useEffect, useState } from "react";
import { get } from "../utils.ts";
import type { User } from "../types.ts";
const Profile = ({ id }: { id: string }) => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>();
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const data = await get<User>(`/users/${id}`);
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchUser();
}, [id]);
if (loading || !user) {
return <div>Loading...</div>;
}
return (
<>
{user && <UserBrief user={user} />}
</>
);
};
The underlying get helper is pure TypeScript and reusable outside React. It appends the endpoint to a base URL, inspects the response status, and either returns parsed JSON or throws for unsuccessful requests.
const baseurl = "https://icodeit.com.au/api/v2";
async function get<T>(url: string): Promise<T> {
const response = await fetch(`${baseurl}${url}`);
if (!response.ok) {
throw new Error("Network response was not ok");
}
return await response.json() as Promise<T>;
}
On the first render React has no user, so it displays a loading indicator in a div. After the effect fires and the response arrives, React re-renders with the user data populated showing name, avatar, and title. Because JavaScript must be parsed and executed before React can render, and because the effect must wait for network data, the user brief section appears only after a noticeable delay.
This structure—a useEffect that issues the request and updates loading/error/user state—appears virtually everywhere in React codebases. Medium-sized applications tend to accumulate many near-identical copies of this pattern scattered across components.
Asynchronous State Handler Pattern
Remote calls are slow and can fail. The UI should never block during those calls, and users need clear signals when a request is in flight or has failed. Encapsulating each remote call in a module that manages the result, progress, and error state gives the UI access to metadata about the request status. The UI can then show alternatives or retry options when the expected result does not arrive.
A minimal implementation exposes a function getAsyncStates that takes a URL and returns the state metadata for the request—in progress, resolved, or failed.
const { loading, error, data } = getAsyncStates(url);
if (loading) {
// Display a loading spinner
}
if (error) {
// Display an error message
}
// Proceed to render using the data
Some callers need to control when the request starts. Returning a fetch function from the state object lets the caller initiate the request at an appropriate time, and a refetch function allows re-issuing the request after an error or when fresh data is needed. These functions can share the same implementation, though refetch may check for cached results and skip the network call when possible.
const { loading, error, data, fetch, refetch } = getAsyncStates(url);
const onInit = () => {
fetch();
};
const onRefreshClicked = () => {
refetch();
};
if (loading) {
// Display a loading spinner
}
if (error) {
// Display an error message
}
// Proceed to render using the data
React Hook Implementation
The pattern translates to a custom Hook that is entirely UI-free but keeps stateful logic shareable. Pulling the data fetching out of the Profile component leaves that component focused on rendering the states returned by the Hook.
import { useEffect, useState } from "react";
import { get } from "../utils.ts";
const useUser = (id: string) => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>();
const [user, setUser] = useState<User | undefined>();
useEffect(() => {
const fetchUser = async () => {
try {
setLoading(true);
const data = await get<User>(`/users/${id}`);
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
fetchUser();
}, [id]);
return {
loading,
error,
user,
};
};
import { useUser } from './useUser.ts';
import UserBrief from './UserBrief.tsx';
const Profile = ({ id }: { id: string }) => {
const { loading, error, user } = useUser(id);
if (loading || !user) {
return <div>Loading...</div>;
}
if (error) {
return <div>Something went wrong...</div>;
}
return (
<>
{user && <UserBrief user={user} />}
</>
);
};
Generalizing with a Service Hook
Fetching distinct data types—user details, product lists, recommendations—often results in near-duplicate fetch functions. An abstraction called useService treats each remote endpoint as a service and centralizes metadata management while accepting a URL parameter.
import { get } from "../utils.ts";
function useService<T>(url: string) {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>();
const [data, setData] = useState<T | undefined>();
const fetch = async () => {
try {
setLoading(true);
const data = await get<T>(url);
setData(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
return {
loading,
error,
data,
fetch,
};
}
This Hook also provides a natural place to centralize error handling, such as mapping specific error types to different messages.
import { useService } from './useService.ts';
const {
loading,
error,
data: user,
fetch: fetchUser,
} = useService(`/users/${id}`);
A variation separates the trigger from the state: useUser exposes state but does not start the request. The calling component initiates the fetch inside useEffect and renders based on the resulting state.
import { useState } from "react";
const useUser = (id: string) => {
// define the states
const fetchUser = async () => {
try {
setLoading(true);
const data = await get<User>(`/users/${id}`);
setUser(data);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
return {
loading,
error,
user,
fetchUser,
};
};
const Profile = ({ id }: { id: string }) => {
const { loading, error, user, fetchUser } = useUser(id);
useEffect(() => {
fetchUser();
}, []);
// render correspondingly
};
Separating stateful data logic from UI lets multiple components share the same state vocabulary while rendering different loading indicators or error messages. A caller that needs a user object with a particular ID just imports the Hook; the underlying fetch and state transitions stay consistent.
When the Pattern Applies
For small applications with only a handful of fetches, keeping data logic inside components is readable and often preferable. As scope grows, so does component depth and duplicated boilerplate. The asynchronous state handler decouples fetching from rendering and alleviates both problems, but introducing it too early adds complexity without payoff. Striking that balance as the project evolves keeps code maintainable.
Fetch-on-Render and the Request Waterfall
A Friends component can reuse useService to request and render a list, while the parent Profile passes only the id prop. Each component manages its own loading and error handling, keeping the code structure almost identical to the Profile implementation.
const Friends = ({ id }: { id: string }) => {
const { loading, error, data: friends } = useService(`/users/${id}/friends`);
// loading & error handling...
return (
<div>
<h2>Friends</h2>
<div>
{friends.map((user) => (
// render user list
))}
</div>
</div>
);
};
const Profile = ({ id }: { id: string }) => {
//...
return (
<>
{user && <UserBrief user={user} />}
<Friends id={id} />
</>
);
};
The component tree stays flat, but the actual network behavior is suboptimal. The Friends component cannot start its request until the parent has a user object to pass down. This is the Fetch-On-Render pattern: rendering pauses while data is requested, then resumes when that data resolves.
React’s render itself takes milliseconds, while the data round-trip often takes seconds. The Friends component spends almost its entire lifecycle idle, waiting on a dependency that could have been requested in parallel. This sequencing of dependent data requests—the Request Waterfall—is a common source of avoidable latency in multi-fetch frontends.
Parallel Data Fetching
In a large application, components that need data are often deeply nested, and different teams may build them independently. That makes it hard to see which components are blocking others downstream. Left unchecked, this creates a request waterfall that degrades the user experience. An obvious fix is to identify independent requests and fire them at the same time.
One way to do this is to centralize data fetching near the root of the component tree. Early in the application lifecycle, you start all data fetches simultaneously. Components that depend on this data then wait only for the slowest request, which typically results in faster overall load times than sequential requests.
Consider a profile page that needs both a user's basic information and their friends list. These two requests are independent, so we can issue them concurrently using the Promise.all method. Promise.all takes an array of promises and returns a single promise that resolves when all input promises resolve, providing their results as an array. If any promise rejects, Promise.all rejects immediately with the reason of the first rejection.
At the application root, we can define a comprehensive data model:
type ProfileState = {
user: User;
friends: User[];
};
const getProfileData = async (id: string) =>
Promise.all([
get<User>(`/users/${id}`),
get<User[]>(`/users/${id}/friends`),
]);
const App = () => {
// fetch data at the very begining of the application launch
const onInit = () => {
const [user, friends] = await getProfileData(id);
}
// render the sub tree correspondingly
}
Implementing Parallel Data Fetching in React
Starting all fetches at launch abstracts the fetching process from subcomponents. In the Profile component, both UserBrief and Friends become pure presentational components that react only to passed data. That lets teams develop them separately and makes them easy to test and modify—the rendering logic is decoupled from the data-fetching logic.
We can create a custom hook, useProfileData, that uses Promise.all to fetch data for a user and their friends in parallel. This optimizes the loading process and structures the result into a predefined shape called ProfileData. The hook provides the Profile component with loading, error, and profileState states, plus a fetchProfileState function the component can call to start the fetch:
import { useCallback, useEffect, useState } from "react";
type ProfileData = {
user: User;
friends: User[];
};
const useProfileData = (id: string) => {
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [profileState, setProfileState] = useState<ProfileData>();
const fetchProfileState = useCallback(async () => {
try {
setLoading(true);
const [user, friends] = await Promise.all([
get<User>(`/users/${id}`),
get<User[]>(`/users/${id}/friends`),
]);
setProfileState({ user, friends });
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, [id]);
return {
loading,
error,
profileState,
fetchProfileState,
};
};
The hook wraps the async fetch function in useCallback so the same function instance is maintained across re-renders unless its dependencies change. This prevents unintended behavior in React's rendering cycle. The Profile component then controls when the fetch happens via useEffect:
const Profile = ({ id }: { id: string }) => {
const { loading, error, profileState, fetchProfileState } = useProfileData(id);
useEffect(() => {
fetchProfileState();
}, [fetchProfileState]);
if (loading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Something went wrong...</div>;
}
return (
<>
{profileState && (
<>
<UserBrief user={profileState.user} />
<Friends users={profileState.friends} />
</>
)}
</>
);
};
This pattern is known as Fetch-Then-Render. The goal is to initiate requests as early as possible during page load, then let the fetched data drive React's render. Because data fetching is not interleaved with rendering, the rendering process stays simple and the code is easier to test and modify. The resulting component tree looks like:
Figure 8: Component structure after refactoring
Sending the two requests in parallel shortens the timeline dramatically. The Friends component can render in milliseconds because, when it mounts, the data is already ready and passed in. The longest wait time is now determined by the slowest request, which is still much faster than sequential requests.
Figure 9: Parallel requests
As applications grow, managing an ever-increasing number of requests at the root becomes unwieldy—especially for components far from the root, where prop drilling becomes burdensome. A common remedy is to store data globally and access it through functions, as offered by Redux or the React Context API, avoiding deep prop chains entirely.
When parallel fetching is the right call
Parallel queries are useful when requests are likely to be slow and don't significantly interfere with each other's performance—which is typical for remote calls, where network latency dominates. The main drawback is that they require some form of asynchronous orchestration, which may be difficult in certain language environments.
The primary reason to avoid parallel fetching is when you cannot know what to request until a prior request completes. For example, on a Profile page, generating a personalized recommendation feed might require first fetching the user's interests from a user API. Only after that response arrives can you construct the request for the feed:
{
"id": "u1",
"name": "Juntao Qiu",
"bio": "Developer, Educator, Author",
"interests": [
"Technology",
"Outdoors",
"Travel"
]
}
Here, the recommendation feed depends on data from the initial call, so parallel fetching is impossible. The second request must wait for the first.
Sequential data requirements also arise in interactive validation. Consider an approval list where each item has a context menu with “Approve” and “Reject” options. If another admin could change the item's status concurrently, the menu must reflect the most current state to avoid conflicting actions. To guarantee accuracy, a service call fetches the item's latest status every time the context menu opens. Those fetches cannot be batched with other parallel activities because the menu contents depend entirely on the real-time response.
Fallback Markup: Declarative Loading States
When a component must handle loading, error, and success states, the logic can quickly become noisy. Even when using custom hooks to encapsulate data fetching, the component still needs flags and conditional rendering to switch between states. Framework-level abstractions can move that bookkeeping out of the component entirely, letting developers write markup that describes what should appear, not how to switch between states.
const Friends = ({ id }: { id: string }) => {
//...
const {
loading,
error,
data: friends,
fetch: fetchFriends,
} = useService(`/users/${id}/friends`);
useEffect(() => {
fetchFriends();
}, []);
if (loading) {
// show loading indicator
}
if (error) {
// show error message component
}
// show the acutal friend list
};
Declarative APIs like JSX make this possible. Instead of juggling state inside a component, the intent reads directly: if there's an error, show the error; while loading, show a loading UI; otherwise, render the data view.
<WhenError fallback={<ErrorMessage />}>
<WhenInProgress fallback={<Loading />}>
<Friends />
</WhenInProgress>
</WhenError>
The same idea underpins the Suspense API in React and an experimental equivalent in Vue. With Suspense, a component states what data it needs and renders when ready; the boundary handles the asynchronous resolution.
Suspense in React
React's Suspense lets you fetch data and render the result without managing loading state inside the data-consuming component:
import useSWR from "swr";
import { get } from "../utils.ts";
function Friends({ id }: { id: string }) {
const { data: users } = useSWR("/api/profile", () => get<User[]>(`/users/${id}/friends`), {
suspense: true,
});
return (
<div>
<h2>Friends</h2>
<div>
{friends.map((user) => (
<Friend user={user} key={user.id} />
))}
</div>
</div>
);
}
Consumers of Friends wrap it in a Suspense boundary, supplying fallback content such as a skeleton placeholder:
<Suspense fallback={<FriendsSkeleton />}>
<Friends id={id} />
</Suspense>
While the component's data dependencies are pending, the fallback renders; once resolved, the default content appears. This keeps the UI responsive and avoids blocking on slow requests.
Vue's Experimental Support
Vue.js is exploring a similar approach with <Suspense>:
<Suspense>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
Loading...
</template>
</Suspense>
On first render, Vue attempts to render the default slot content. If it encounters asynchronous dependencies, it enters a pending state and shows the fallback. After the dependencies complete, it transitions to resolved and displays the actual content.
Who Owns the Fallback?
Where to place the skeleton or spinner is a design choice. Without fallback markup, the component that fetches data decides when to show each state:
const Friends = ({ id }: { id: string }) => {
// Data fetching logic here...
if (loading) {
// Display loading indicator
}
if (error) {
// Display error message component
}
// Render the actual friend list
};
With fallback markup, that responsibility moves to the consumer of the component:
<Suspense fallback={<FriendsSkeleton />}>
<Friends id={id} />
</Suspense>
The right boundary for this decision affects the user experience. A parent that stops showing its own loading indicator while a child continues can feel disjointed. The granularity of loading states — per component, per section, or per whole page — should follow the interaction pattern you intend. There is no single correct level; consistency matters more than uniformity. Treating the fallback as the "empty data" variant of the same logical component, akin to the Special Case pattern, can help keep the mental model clean.
Trade-Offs to Weigh
Fallback Markup improves code clarity by removing boilerplate and standardizing how async states render. It pays off when you rely on shared components for loading, error, and empty states across the app, and when deep component trees benefit from centralized loading orchestration.
But the approach is only as strong as the framework support beneath it. React's Suspense for data fetching currently depends on third-party libraries, and Vue's Suspense is still experimental. In simpler applications, managing states inline may be less overhead than introducing a fallback system. Also note that a generic fallback reduces the ability to differentiate error handling: if distinct error types require distinct UI, a single fallback prop may not be enough.
Adding a Hover Detail Card
Suppose we need to show a popup when a user hovers over a Friend item so they can see additional profile details without navigating away.
This triggers a second request to fetch details like the user's homepage and connection count. Updating the list item to include a UserDetailCard inside a Popover adds the needed interaction:
import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react";
import { UserBrief } from "./user.tsx";
import UserDetailCard from "./user-detail-card.tsx";
export const Friend = ({ user }: { user: User }) => {
return (
<Popover placement="bottom" showArrow offset={10}>
<PopoverTrigger>
<button>
<UserBrief user={user} />
</button>
</PopoverTrigger>
<PopoverContent>
<UserDetailCard id={user.id} />
</PopoverContent>
</Popover>
);
};
The UserDetailCard mirrors the Profile component — it requests data on mount and renders the result. The UI components come from nextui, which provides accessible, ready-to-use primitives for this kind of overlay.
export function UserDetailCard({ id }: { id: string }) {
const { loading, error, detail } = useUserDetail(id);
if (loading || !detail) {
return <div>Loading...</div>;
}
return (
<div>
{/* render the user detail*/}
</div>
);
}
There is, however, a practical concern: nextui is a sizeable package, and not every user will hover over a card. Loading the full dependency set for all visitors—even those who never trigger the detail view—is wasteful. A better approach is to lazy-load the UserDetailCard itself, so the code for the hover popup only arrives when the interaction actually happens. That keeps the initial bundle lean without delaying the feature for the users who do use it. This lazy-loading trade-off, along with boundary placement for async states, is a recurring design decision across the patterns discussed so far.
Code Splitting: Loading Only What’s Needed
Large single-page applications often suffer from oversized JavaScript bundles that delay initial rendering. Code splitting mitigates this by breaking the bundle into smaller chunks that load on demand—either in response to user actions or proactively, without blocking the critical rendering path. This is typically handled at build time, where non-essential modules are isolated into separate files.
The Dynamic Import Operator
JavaScript’s dynamic import operator enables asynchronous module loading. Although it resembles a function call like import("./user-detail-card.tsx"), import is a keyword, not a function. It lets you defer loading a module until it’s actually needed—for instance, only when a button is clicked:
button.addEventListener("click", (e) => {
import("/modules/some-useful-module.js")
.then((module) => {
module.doSomethingInteresting();
})
.catch(error => {
console.error("Failed to load the module:", error);
});
});
The module stays out of the initial page load. The import() call sits inside an event listener, so it executes only if the user interacts with that button.
Both React and Vue.js simplify this pattern. React offers React.lazy and Suspense: wrap the import with React.lazy, then wrap the component—say UserDetailCard—with Suspense. React defers rendering until the module arrives, showing a fallback UI during the load:
import React, { Suspense } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@nextui-org/react";
import { UserBrief } from "./user.tsx";
const UserDetailCard = React.lazy(() => import("./user-detail-card.tsx"));
export const Friend = ({ user }: { user: User }) => {
return (
<Popover placement="bottom" showArrow offset={10}>
<PopoverTrigger>
<button>
<UserBrief user={user} />
</button>
</PopoverTrigger>
<PopoverContent>
<Suspense fallback={<div>Loading...</div>}>
<UserDetailCard id={user.id} />
</Suspense>
</PopoverContent>
</Popover>
);
};
The snippet defines a Friend component that displays a user-details popover from Next UI on interaction. Using React.lazy keeps UserDetailCard out of the main bundle; Suspense provides a loading fallback in the meantime.
This sequencing matters. When the user hovers and the bundled JavaScript downloads, the browser must also parse that code. Only after that does the app call /users/<id>/details to fetch data, which then renders the popup’s content.
When Code Splitting Makes Sense—and When It Doesn’t
Splitting bundles saves bandwidth and lets users fetch only what they need. But it can also hurt perceived performance. If hovering a button triggers a bundle load, users might wait seconds for the JavaScript to download, parse, and execute before anything renders. That delay happens only on first interaction, yet it can feel sluggish.
A well-placed skeleton or loading indicator via Suspense improves perceived speed. Alternatively, if the separate chunk isn’t large, folding it into the main bundle may be simpler—hover responses stay immediate without extra loading steps.
Lazy Loading Beyond React
Other libraries adopt the same idea. Vue.js provides defineAsyncComponent, which lazy-loads a component only when it needs to render, much like React.lazy:
<template>
<Popover placement="bottom" show-arrow offset="10">
<!-- the rest of the template -->
</Popover>
</template>
<script>
import { defineAsyncComponent } from 'vue';
import Popover from 'path-to-popover-component';
import UserBrief from './UserBrief.vue';
const UserDetailCard = defineAsyncComponent(() => import('./UserDetailCard.vue'));
// rendering logic
</script>
Watch for the request-waterfall pattern here: the JavaScript bundle downloads first, then executes and calls the user-details API, adding latency. A better approach would fetch the bundle and the API request in parallel. On hovering a Friend component, you could trigger the data request and cache its result, so the component renders immediately once its code arrives.
Prefetching: Preparing for the Next Interaction
Prefetching loads data or resources before they’re needed, reducing latency during later operations. It’s most useful when user actions are predictable—navigating to a route, opening a modal, or hovering over an element that reveals remote data.
Implementations vary from a native HTML <link> tag with rel="preload" to programmatic calls via the fetch API. When the URLs are known ahead of time, the simplest approach places <link> tags in the HTML <head>:
<!doctype html>
<html lang="en">
<head>
<link rel="preload" href="https://martinfowler.com/bootstrap.js" as="script">
<link rel="preload" href="https://martinfowler.com/users/u1" as="fetch" crossorigin="anonymous">
<link rel="preload" href="https://martinfowler.com/users/u1/friends" as="fetch" crossorigin="anonymous">
<script type="module" src="https://martinfowler.com/app.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
With this, the browser requests bootstrap.js and the user API as soon as the HTML parses—well before other scripts run. The results are cached, ready for your application on startup.
More often, URLs aren’t known in advance. That calls for dynamic prefetching, typically triggered by events. A mouseover listener on a button, for example, can prefetch data into local state or a cache. When the related component eventually renders, it reads from that storage—for instance, sessionStorage—and skips the loading state:
document.getElementById('button').addEventListener('mouseover', () => {
fetch(`/user/${user.id}/details`)
.then(response => response.json())
.then(data => {
sessionStorage.setItem('userDetails', JSON.stringify(data));
})
.catch(error => console.error(error));
});
Prefetching in Practice with React and SWR
In React, the swr package provides a preload function (despite the name, it performs prefetching). Register an onMouseEnter handler on the popover’s trigger component:
import { preload } from "swr";
import { getUserDetail } from "../api.ts";
const UserDetailCard = React.lazy(() => import("./user-detail-card.tsx"));
export const Friend = ({ user }: { user: User }) => {
const handleMouseEnter = () => {
preload(`/user/${user.id}/details`, () => getUserDetail(user.id));
};
return (
<Popover placement="bottom" showArrow offset={10}>
<PopoverTrigger>
<button onMouseEnter={handleMouseEnter}>
<UserBrief user={user} />
</button>
</PopoverTrigger>
<PopoverContent>
<Suspense fallback={<div>Loading...</div>}>
<UserDetailCard id={user.id} />
</Suspense>
</PopoverContent>
</Popover>
);
};
This shrinks the popup’s render time noticeably. When a user hovers over a Friend, the browser downloads both the dedicated JavaScript bundle and the user-details data at once. By the time UserDetailCard mounts, the data is already cached, so it renders instantly.
Data fetching and loading shift upward to the Friend component; UserDetailCard simply reads from the local cache that swr maintains:
import useSWR from "swr";
export function UserDetailCard({ id }: { id: string }) {
const { data: detail, isLoading: loading } = useSWR(
`/user/${id}/details`,
() => getUserDetail(id)
);
if (loading || !detail) {
return <div>Loading...</div>;
}
return (
<div>
{/* render the user detail*/}
</div>
);
}
In this component, useSWR fetches user details based on the given id, with caching, revalidation, and built-in error handling. A loading state shows until the data arrives; then the component renders the details.
Across these strategies—async state handling, parallel fetching, fallback markup, code splitting, and prefetching—the goals are consistent: parallelize where possible, load non-critical resources interactively, and prepare data so that when users ask, the answer is already there.
When to Prefetch
Apply prefetching when initial load times feel slow or many features aren’t needed right away but likely will be shortly. It shines for interaction-triggered resources, like mouse-over reveals or modal popups. When the browser is busy fetching scripts or assets, prefetching can fill idle network time—smoothing demand over time instead of causing spikes.
Resist adding this complexity prematurely. Only consider it once performance issues surface, especially on initial load or for mobile users with limited bandwidth and slower JavaScript engines. Remember that simpler measures—caching, CDNs for static assets, and compression—can boost performance without custom code. Prefetching works only when your guesses about user behavior are correct. Misses waste resources and can delay genuinely needed content.
Combining strategies in practice
In most real applications, the question isn't which single pattern to adopt — it's how to combine several of them. A common architecture pairs Server-Side Rendering for static shell content with Fetch-Then-Render for dynamic data. Lazy-loaded segments, possibly combined with Prefetching triggered by user hover or click, keep the initial payload lean while preserving a fast experience for elements the user actually needs.
Consider a typical issue-tracking page. The navigation and sidebar are static and render first, giving context immediately. Above the fold, the title, description, and key fields like Reporter and Assignee load via a standard fetch pattern. For longer content such as History, the data is fetched only when the user interacts with that area — a lazy approach that avoids pulling resources that may never be viewed.
Selection also depends on your toolchain. Code Splitting, for instance, requires bundler support. Upgrading to a bundler with that capability isn't always practical, especially if you're constrained by older, less stable infrastructure. Evaluate your existing setup before committing to a pattern that adds new dependencies.
Key takeaways
Across the patterns covered in this article, a few principles stand out for building efficient data-fetching applications:
- Asynchronous State Handler: Use custom hooks or composable APIs to abstract fetching and state management away from components. This centralizes async logic, keeps components simpler, and improves reusability.
- Fallback Markup: Leveraging Suspense for async fetching yields a more declarative codebase and smoother loading experience.
- Parallel Data Fetching: Running requests in parallel reduces wait time and makes the app feel more responsive.
- Code Splitting: Lazy-load non-essential parts at initial load and use Suspense to handle loading states gracefully. This helps keep the main bundle performant.
- Prefetching: Anticipating user actions (e.g., hover or click) and loading that data beforehand delivers a fast, seamless experience.
Although demonstrated here in React, these techniques are framework-agnostic. They translate well to other libraries and architectures. Implemented thoughtfully, they promote applications that are both scalable and responsive, delivering content to users without unnecessary delay.
Acknowledgements
The author thanks Martin Fowler for insights that shaped the structure and content. Thanks also to colleagues at Atlassian, particularly the Jira team, whose complex codebase provided examples of these patterns in production.
Individual contributors include Jason Sheehy for demonstrating Code Splitting in a live project, Tom Gasson for collaboration on Prefetching and Fallback Markup experiments, and Dmitry Gonchar for inspiring the Asynchronous State Handler pattern through early work on useService in Jira.



