What One Roundtrip Means for Web Apps
In the classic web model, navigating to a new page is simple: click a link, the browser fetches the HTML, and the page renders. All the data needed to display that page is already embedded in the HTML — the HTML is the data, and no further client-side processing is required.
That model breaks down when application logic moves to the client. The data you want to display is determined by the UI, and the UI is determined by the route. The question becomes: how many API requests does it take to load the data for one screen?
From REST Resources to Waterfalls
With JSON APIs, a common approach is to expose one endpoint per conceptual “resource” — a post, a comment, a user. If a page needs to show a post and its comments, that’s two endpoints. If it needs to show ten items, that’s ten requests, or worse, a chain of requests where each depends on the previous one's response.
This was never a real problem when the API lived on your own server next to your data layer. You could hit multiple REST endpoints during a single request and still return one HTML document. But once the client starts calling those endpoints directly, the client/server waterfall emerges — and you have no levers to fix it. You can’t move the user closer to the server, and you can’t change the shape of the API responses.
<article>
<h1>One Roundtrip Per Navigation</h1>
<p>How many requests should it take to navigate to another page?</p>
<ul class="comments">
<li>You're just reinventing HTML</li>
<li>You're just reinventing PHP</li>
<li>You're just reinventing GraphQL</li>
<li>You're just reinventing Remix</li>
<li>You're just reinventing Astro</li>
</ul>
</article>Colocation vs. Efficiency
Because the UI determines the data, it feels natural to place data-fetching logic next to the component that consumes it. That’s the old $.ajax in a Backbone.View idea, or fetch inside useEffect. The benefit is colocation — the code that says what data is needed lives with the code that renders it. Different developers can own different components that depend on different endpoints, and compose them freely.
const [post, comments] = await Promise.all([
fetch(`/api/posts/${postId}`).then(res => res.json()),
fetch(`/api/posts/${postId}/comments`).then(res => res.json())
]);The downside is that inefficiencies become invisible. A single edit to a shared component can introduce a new client/server waterfall across a dozen screens. With components on the client, you lose the server-side options — in-process data layers, caching, or moving the deployment closer to the data source — that make even inherent waterfalls cheap.
Adding structure to data fetching doesn’t inherently fix this. A query library like React Query improves caching and API ergonomics, but it still allows the same N-queries-for-N-items patterns and client/server waterfalls.
function PostContent({ postId }) {
const [post, setPost] = useState()
useEffect(() => {
fetch(`/api/posts/${postId}`)
.then(res => res.json())
.then(setPost);
}, []);
if (!post) {
return null;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments postId={postId} />
</article>
);
}
function Comments({ postId }) {
const [comments, setComments] = useState([])
useEffect(() => {
fetch(`/api/posts/${postId}/comments`)
.then(res => res.json())
.then(setComments);
}, [])
return (
<ul className="comments">
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}Client-side caching is a useful tool, but it’s not a panacea. It helps when a user navigates back or switches tabs, when content genuinely couldn’t have changed. But clicking a link, users expect fresh content — showing stale content and then replacing it with a revalidation is often worse than waiting for the initial load. Caching doesn’t reduce the number of requests when you need fresh data, and it can’t prevent client/server waterfalls.
Route Loaders: Centralizing Data Fetching
One way out of the colocation trap is to give it up entirely. Define a loader per route — a function that runs before any component renders, gathers all data for that route, and hands it down the component tree. React Router’s clientLoader is a concrete example of this pattern, but the idea generalizes across routers.
function usePostQuery(postId) {
return useQuery(
['post', postId],
() => fetch(`/api/posts/${postId}`).then(res => res.json())
);
}
function usePostCommentsQuery(postId) {
return useQuery(
['post-comments', postId],
() => fetch(`/api/posts/${postId}/comments`).then(res => res.json())
);
}
function PostContent({ postId }) {
const { data: post } = usePostQuery(postId);
if (!post) {
return null;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments postId={postId} />
</article>
);
}
function Comments({ postId }) {
const { data: comments } = usePostCommentsQuery(postId);
return (
<ul className="comments">
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}The downside is clear: data requirements are no longer colocated. The top-level loader has to know everything the entire component hierarchy needs. If a component adds a data dependency, the corresponding route loader must be updated to match.
The upside is that waterfalls become visible. They’re still possible, but they no longer happen by default simply because a component fetches in isolation.
Moving Loaders to the Server
If the loader is a self-contained function that runs before components, it can just as easily run on the server. This is the model behind React Router’s server loader function and Next.js's getServerSideProps().
async function clientLoader({ params }) {
const { postId } = params;
const [post, comments] = await Promise.all([
fetch(`/api/posts/${postId}`).then(res => res.json()),
fetch(`/api/posts/${postId}/comments`).then(res => res.json())
]);
return { post, comments };
}The server is the natural home for data fetching — it has the levers. You can move the server closer to the data source, add cross-request caching, or even skip HTTP entirely and call an in-process data layer directly. Each loader can request just the fields its screen needs, without “expanding REST resources.”
// This could run on the server instead
async function loader({ params }) {
const { postId } = params;
const [post, comments] = await Promise.all([
fetch(`/api/posts/${postId}`).then(res => res.json()),
fetch(`/api/posts/${postId}/comments`).then(res => res.json())
]);
return { post, comments };
}From the client’s perspective, this effectively restores the HTML app behavior: data arrives in a single roundtrip, and client/server waterfalls simply cannot happen.
Server Functions: Convenience Without Efficiency
But what if we want colocation back, and we want it on the server? One idea is to write a loader per component. Server Functions (whether TanStack or React Server Functions) make this syntactically painless — components can import a function that executes on the server directly, no explicit API route required.
The problem is that using Server Functions for colocated fetching doesn’t solve any of the original issues. It gives you nicer syntax but regresses the performance characteristics back to those of fetching inside components. Each component making its own server call reintroduces client/server waterfalls and multi-request navigations. Server Functions reduce the plumbing of calling the server but don’t improve how data is fetched.
GraphQL Fragments: Composing Data Requirements
GraphQL, when used as intended, actually addresses this tension. The idea is that individual components declare their data dependencies as fragments that compose together. A Comment component declares exactly its own data needs; a PostContent component composes Comment’s fragment into its own. The actual data fetching happens at the top of the route, but it’s derived automatically from the component code.
import { loadPost, loadComments } from 'my-data-layer';
async function loader({ params }) {
const { postId } = params;
const [post, comments] = await Promise.all([
loadPost(postId),
loadComments(postId)
]);
return { post, comments };
}import { createServerFn } from '@tanstack/react-start'
import { loadPost, loadComments } from 'my-data-layer';
export const getPost = createServerFn({ method: 'GET' }).handler(
async (postId) => loadPost(postId)
);
export const getComments = createServerFn({ method: 'GET' }).handler(
async (postId) => loadComments(postId)
);Each screen can be fetched with exactly one query that describes everything it renders. If a component’s data needs change, its fragment changes, and all composed queries update accordingly. GraphQL makes each navigation a single roundtrip — it provides the efficiency of server loaders alongside queried colocation that others only approximate.
Server Components: Loaders Composing Loaders
React Server Components approach the same problem from a different angle. Instead of giving each component a loader function that returns the component, the server executes a component as a loader — the component is the loader, returning the client element tree.
'use server';
import { loadPost, loadComments } from 'my-data-layer';
export async function getPost(postId) {
return loadPost(postId);
}
export async function getComments(postId) {
return loadComments(postId);
}import { getPost } from './my-server-functions';
import { Comments } from './Comments';
function usePostQuery(postId) {
return useQuery(['post', postId], () => getPost(postId));
}
function PostContent({ postId }) {
const { data: post } = usePostQuery(postId);
if (!post) {
return null;
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Comments postId={postId} />
</article>
);
}This resembles the old Container/Presentational pattern, but all “containers” run on the server to eliminate client roundtrips. If you think of an RSC file as a Server Loader that returns components instead of data, the architecture becomes clear.
The practical benefits: You get the efficiency of server loaders — no client/server waterfalls, data arrives in one request. You get the colocation of component-driven development, with server props discoverable a single hop away. And there’s no separate API to learn; you return a component tree.
import { getComments } from './my-server-functions';
function usePostCommentsQuery(postId) {
return useQuery(['post-comments', postId], () => getComments(postId));
}
export function Comments({ postId }) {
const { data: comments } = usePostCommentsQuery(postId);
return (
<ul className="comments">
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}For a route like a post with comments, the client sends a single request during navigation, and the server returns the entire streamed tree, data included.
Trade-offs Worth Remembering
- Fetching in a single roundtrip can be inefficient if one part of the response is slow. Server Components handle this with streaming; GraphQL offers the
@deferdirective. - Prefetching does not solve inherent client/server waterfalls because fresh requests quickly become dependent all over again — the user simply can’t be moved closer to the origin.
- Fetching in components can be acceptable when your data layer has very low latency, but on the client for a Web app, there are few situations in which this is the best option.
Not many data-fetching architectures aim to solve both colocation and efficiency simultaneously. HTML templates do it (Astro is one modern example), GraphQL does it, and Server Components do it too. Whatever framework you pick, that’s the question worth asking.



