Guides
Request waterfalls
What a request waterfall is, why it's the biggest performance problem in data fetching, and how loaders, routes and backends remove them.
A request waterfall is a request that can't start until another one has finished. Each level of the waterfall costs a full round trip, and on a slow connection a round trip can be hundreds of milliseconds. Removing waterfalls is usually the single most effective thing you can do for an app's loading time.
raf-query is designed around this. Queries start in route loaders and server components, where everything a page needs is known up front, so they start together:
1. |-> Post
1. |-> Comments
1. |-> Author
instead of one after the other as each component renders and discovers what it needs:
1. |-> Post
2. |-> Comments
3. |-> Author
The demo below runs the same page both ways. Open a post with each strategy and watch the Queries panel: with the loader, all three requests load at once; in render, they load one after another.
Fetch-on-render
The classic waterfall comes from fetching where data is used. A component fetches its data, suspends, and only renders its children, which fetch their own data, once that request is done:
function function Post(props: {
slug: string;
}): React.JSX.Element
Post(props: {
slug: string;
}
props: { slug: stringslug: string }) {
// Fetched during render: nothing below starts until this resolves.
const const post: {
title: string;
}
post = function useSuspenseFetch(url: string): {
title: string;
}
useSuspenseFetch(`/posts/${props: {
slug: string;
}
props.slug: stringslug}`);
return (
<>
<function PostBody(props: {
readonly post: unknown;
}): React.ReactNode
PostBody post: unknownpost={const post: {
title: string;
}
post} />
<const Suspense: React.ExoticComponent<React.SuspenseProps>Lets you display a fallback until its children have finished loading.
Suspense React.SuspenseProps.fallback?: React.ReactNodeA fallback react tree to show when a Suspense child (like React.lazy) suspends
fallback={<function CommentsSkeleton(): React.ReactNodeCommentsSkeleton />}>
<function Comments(props: {
readonly slug: string;
}): React.ReactNode
Comments slug: stringslug={props: {
slug: string;
}
props.slug: stringslug} />
</const Suspense: React.ExoticComponent<React.SuspenseProps>Lets you display a fallback until its children have finished loading.
Suspense>
</>
);
}
Suspense makes this worse, not better: a suspending component renders nothing below it, so its children can't even begin to fetch.
raf-query has no fetch-on-render hook. The loader starts every query, and useQuery reads what it started:
export const const Route: Route<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 15 more ..., undefined>
Route = createFileRoute<"/posts/$slug", Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, "/posts/$slug", "/$slug", "/posts/$slug">(path?: "/posts/$slug" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
Creates a file-based Route factory for a given path.
createFileRoute("/posts/$slug")({
FilebaseRouteOptionsInterface<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, ... 8 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async (args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args) => {
const { const slug: stringslug } = args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.params: {
slug: string;
}
params;
const [const handles: {
readonly post: QueryHandle<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
readonly comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles] = await load<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}): Promise<...>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(args: LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/posts", "/posts", "/posts", "/posts", ZodObject<{
sort: ZodDefault<ZodEnum<{
newest: "newest";
oldest: "oldest";
}>>;
page: ZodDefault<ZodInt>;
}, $strip>, ResolveParams<"/posts">, AnyContext, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>
args.LoaderFnContext<Register, Route<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 15 more ..., undefined>, ... 7 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<any>queryClient, {
// Waits only if the post isn't in the cache yet.
post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post: swr<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
Use cached data even when it's stale, and wait only when the cache has
none. Stale data resolves at once and refetches in the background, as
HTTP's
stale-while-revalidate
does. The load rejects if the fetch it waits for fails.
swr(function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug }),
// Never waits: the comments load while the page renders.
comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments: prefetch<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery, { slug: stringslug }),
});
return const handles: {
readonly post: QueryHandle<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
readonly comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
handles;
},
UpdatableRouteOptionsExtensions.component?: RouteComponent | undefinedcomponent: function PostPage(): React.JSX.ElementPostPage,
});
Both requests start the moment the navigation does, before any component renders. With defaultPreload: "intent", they start when the user hovers the link.
Start every query before awaiting any
Inside a loader, put every query in one load. It starts every entry before it waits for any, so their order doesn't matter. A second await load(…) after a first turns two parallel requests back into a waterfall:
{
// ❌ the comments wait for the post
const [const post: {
readonly post: QueryHandle<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
post] = await load<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}): Promise<Loaded<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<{
readonly meta: Flat<unknown>;
}>
qc, { post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post: swr<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
Use cached data even when it's stale, and wait only when the cache has
none. Stale data resolves at once and refetches in the background, as
HTTP's
stale-while-revalidate
does. The load rejects if the fetch it waits for fails.
swr(function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug }) });
const [const comments: {
readonly comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
}
comments] = await load<{
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
}): Promise<...>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<{
readonly meta: Flat<unknown>;
}>
qc, {
comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments: prefetch<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery, { slug: stringslug }),
});
}
{
// ✅ both start together
const [const handles: {
readonly comments: QueryHandle<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>;
readonly post: QueryHandle<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
handles] = await load<{
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}>(client: QueryClient, entries: {
readonly comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">;
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">;
}): Promise<...>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<{
readonly meta: Flat<unknown>;
}>
qc, {
comments: LoadEntry<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, "prefetch">
comments: prefetch<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>(factory: (input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>>): LoadEntry<...>
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function commentsQuery(input: {
slug: string;
}): InfiniteQueryOptions<readonly ["posts", "comments", string], CommentPage, number, InfiniteData<CommentPage, number>, DefaultTypes>
commentsQuery, { slug: stringslug }),
post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
post: swr<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "swr">
Use cached data even when it's stale, and wait only when the cache has
none. Stale data resolves at once and refetches in the background, as
HTTP's
stale-while-revalidate
does. The load rejects if the fetch it waits for fails.
swr(function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug }),
});
}Several queries the route can't render without are swr entries in the same load, which waits for them together.
Dependent queries
A query whose input comes from another query's result is a waterfall by construction. A post page that shows "more by this author" needs authorQuery({ id: post.authorId }), and authorId is only known once the post has loaded, so the author's request can't start before the post's has finished. Fix it where the data is shaped, so every query a route needs can start from what the URL already holds:
- Put the input in the route. If the page is about an organization, its id belongs in the path (
/orgs/$orgId), not in a lookup on the current user. - Have the backend resolve it. The server already knows the session and the relations, so an endpoint can return the child with its parent (the post with its author), or be keyed by what the client has (
GET /posts/:slug/authorinstead of the post thenGET /authors/:id, orGET /me/orginstead ofGET /methenGET /orgs/:id). The demo above does this: its loader starts the author from the post's id.
Example: the current user's organization
A settings page needs the user and their organization, and the organization's id is a field of the user:
export const const Route: Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings", "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route = createFileRoute<"/settings", RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings">(path?: "/settings" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
Creates a file-based Route factory for a given path.
createFileRoute("/settings")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args) => {
const const qc: QueryClient<any>qc = args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args.LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<any>queryClient;
const [const userHandles: {
readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>;
}
userHandles, const data: LoadedData<{
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">;
}>
data] = await load<{
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">;
}>(client: QueryClient, entries: {
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">;
}): Promise<Loaded<{
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">;
}>>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<any>qc, {
user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">
user: revalidate<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>(factory: () => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">
Wait for fresh data, as HTTP's
must-revalidate
does. Fresh cached data resolves at once without a refetch (staleTime
is the max-age); with stale data or none it waits for the fetch. On a
server client, data counts as fresh until a write invalidates it. The
load rejects if the fetch fails.
revalidate(function currentUserQuery(): QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>
currentUserQuery),
});
const [const orgHandles: {
readonly org: QueryHandle<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>>;
}
orgHandles] = await load<{
readonly org: LoadEntry<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
readonly org: LoadEntry<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, "prefetch">;
}): Promise<Loaded<{
readonly org: LoadEntry<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, "prefetch">;
}>>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<any>qc, {
org: LoadEntry<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, "prefetch">
org: prefetch<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>>(factory: (input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, ...args: LoadEntryArgs<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>>): LoadEntry<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>, "prefetch">
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function orgQuery(input: {
orgId: string;
}): QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>
orgQuery, { orgId: stringorgId: const data: LoadedData<{
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "revalidate">;
}>
data.user: {
orgId: string;
}
user.orgId: stringorgId }),
});
return { ...const userHandles: {
readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>;
}
userHandles, ...const orgHandles: {
readonly org: QueryHandle<(input: {
orgId: string;
}) => QueryOptions<readonly ["org", string], {
id: string;
}, {
id: string;
}, DefaultTypes>>;
}
orgHandles };
},
});With a 150 ms round trip, the organization can't arrive before 300 ms, and the navigation is blocked for the first 150 even though nothing on the page needed the user that early.
Fix it in the backend: the server can find the session's organization itself, so give it an endpoint and a query with no input.
export function function currentOrgQuery(): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>currentOrgQuery() {
return queryOptions<readonly ["org", "current"], Org, Org>(options: BaseOptions<readonly ["org", "current"], DefaultTypes> & {
readonly queryFn: (context: QueryFunctionContext<readonly ["org", "current"]>) => Awaitable<Org>;
readonly select?: ((data: Org) => Org) | undefined;
}): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes> (+1 overload)
Typed options for a query factory:
queryOptions({
BaseOptions<readonly ["org", "current"], DefaultTypes>.queryKey: readonly ["org", "current"]A plain array, usually built by a group(). Handles keep its literal type.
queryKey: const org: Group
<readonly ["current"]>(rest_0: "current") => readonly ["org", "current"]
org("current"),
queryFn: (context: QueryFunctionContext<readonly ["org", "current"]>) => Awaitable<Org>queryFn: () => function getCurrentOrg(): Promise<Org>getCurrentOrg(),
});
}
export const const Route: Route<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings", "/settings", undefined, ResolveParams<"/settings">, AnyContext, AnyContext, AnyContext, {}, (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, ... 4 more ..., undefined>
Route = createFileRoute<"/settings", RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", "/settings", "/settings">(path?: "/settings" | undefined): <TRegister, TSearchValidator, TParams, TRouteContextFn, TBeforeLoadFn, TLoaderDeps, TLoaderFn, TChildren, TSSR, TMiddlewares, THandlers>(options?: (ParamsOptions<...> & ... 1 more ... & UpdatableRouteOptions<...>) | undefined) => Route<...>
Creates a file-based Route factory for a given path.
createFileRoute("/settings")({
FilebaseRouteOptionsInterface<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, {}, ... 5 more ..., undefined>, ... 12 more ..., undefined>.loader?: Constrain<(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, ... 7 more ..., undefined>, ... 7 more ..., undefined>) => Promise<...>, RouteLoaderFn<...> | RouteLoaderObject<...>> | undefined
loader: async (args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args) => {
const [const handles: {
readonly org: QueryHandle<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>;
readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>;
}
handles] = await load<{
readonly org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">;
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
readonly org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">;
readonly user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "prefetch">;
}): Promise<...>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(args: LoaderFnContext<Register, RootRoute<Register, undefined, {
queryClient: QueryClient;
}, AnyContext, AnyContext, {}, undefined, unknown, unknown, unknown, unknown, undefined>, "/settings", ResolveParams<"/settings">, {}, AnyContext, AnyContext, AnyContext, unknown, undefined>
args.LoaderFnContext<Register, RootRoute<Register, undefined, { queryClient: QueryClient; }, AnyContext, AnyContext, ... 6 more ..., undefined>, ... 7 more ..., undefined>.context: {
queryClient: QueryClient;
}
context.queryClient: QueryClient<any>queryClient, {
org: LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">org: prefetch<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>(factory: () => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>, "prefetch">Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function currentOrgQuery(): QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>currentOrgQuery),
user: LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "prefetch">
user: prefetch<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>(factory: () => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, ...args: LoadEntryArgs<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>): LoadEntry<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>, "prefetch">
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function currentUserQuery(): QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>
currentUserQuery),
});
return const handles: {
readonly org: QueryHandle<() => QueryOptions<readonly ["org", "current"], Org, Org, DefaultTypes>>;
readonly user: QueryHandle<() => QueryOptions<readonly ["user", "current"], {
orgId: string;
}, {
orgId: string;
}, DefaultTypes>>;
}
handles;
},
});Or fix it in the routes: links into the page come from places that already know the organization (the org switcher, a list of orgs), so they can put its id in the URL.
Either way, both requests start together, and the page is ready after one round trip instead of two.
When the data really can't be reshaped (a third-party API you don't control), you have two options, and both pay the extra round trip:
- In the loader: load the post with
revalidate, then build the author's input fromload's data. The navigation waits for both. - In a component: read the author with a
waterfallHandle, below. The rest of the page renders first.
Render-time inputs: waterfallHandle
When an input is only known at render time (the post's authorId), no loader can start the query. waterfallHandle builds its handle from the factory and the input, so the consumer looks the same as one fed by a loader. It starts nothing: the component that reads the handle fetches while it renders.
function function PostAuthor(props: {
post: Post;
}): React.JSX.Element
PostAuthor(props: {
post: Post;
}
props: { post: Postpost: Post }) {
const const handle: QueryHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
handle = waterfallHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, input: {
id: string;
}): QueryHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>> (+1 overload)
The handle of a query whose input is only known while rendering, like an
id in another query's data. It starts nothing: the component that reads
it fetches when it renders, which makes it a request waterfall, and the
name keeps that visible. Prefer starting queries in a loader with load.
waterfallHandle(function authorQuery(input: {
id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery, { id: stringid: props: {
post: Post;
}
props.post: Postpost.Post.authorId: stringauthorId });
const [const authorThenable: QueryValue<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
authorThenable] = useQuery<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, handle: QueryHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>, options?: UseQueryOptions): readonly [...]
Read a query by its handle.
useQuery(function authorQuery(input: {
id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery, const handle: QueryHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
handle);
const const author: Authorauthor = use<Author>(usable: React.Usable<Author>): Authoruse(const authorThenable: QueryValue<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>
authorThenable);
return <function AuthorCard(props: {
readonly author: unknown;
}): React.ReactNode
AuthorCard author: unknownauthor={const author: Authorauthor} />;
}
The reader fetches only when the cache has no data for the query. Cached data, even stale, renders as is, and refetches the way any read query does: when it's invalidated, on window focus, and so on.
The name is the warning: each waterfall is an extra round trip after render. Search a codebase for waterfallHandle and you have a list of every waterfall in it. In development, useQuery warns in the console when it fetches a query that nothing loaded and that didn't come from waterfallHandle, so accidental waterfalls show up too.
On the server, the waterfall happens there or moves to the browser. In TanStack Start, the reader fetches during the server render and the router streams the result. In React Router, it fetches during the server render, and the browser fetches it again. Under server components, the reader's Suspense boundary renders its fallback on the server, and the query loads in the browser (see SSR and hydration). To fetch it once, on the server, start the dependent query in the loader or server component instead:
const [const postHandles: {
readonly post: QueryHandle<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>;
}
postHandles, const data: LoadedData<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>
data] = await load<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>(client: QueryClient, entries: {
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}): Promise<Loaded<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<{
readonly meta: Flat<unknown>;
}>
qc, {
post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">
post: revalidate<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>(factory: (input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, ...args: LoadEntryArgs<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>>): LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">
Wait for fresh data, as HTTP's
must-revalidate
does. Fresh cached data resolves at once without a refetch (staleTime
is the max-age); with stale data or none it waits for the fetch. On a
server client, data counts as fresh until a write invalidates it. The
load rejects if the fetch fails.
revalidate(function postQuery(input: {
slug: string;
}): QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>
postQuery, { slug: stringslug }),
});
const [const authorHandles: {
readonly author: QueryHandle<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>;
}
authorHandles] = await load<{
readonly author: LoadEntry<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}>(client: QueryClient, entries: {
readonly author: LoadEntry<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}): Promise<Loaded<{
readonly author: LoadEntry<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">;
}>>
Start a loader's queries and return a serializable handle for each. It
starts every entry before it waits for any, so the order of the entries
can't create a waterfall. Resolves with [handles, data] once every
swr and revalidate entry has data, and rejects with the error as soon
as one fails.
prefetch entries never make it reject. A skipToken in place of an
entry starts nothing and gets no handle.
load(const qc: QueryClient<{
readonly meta: Flat<unknown>;
}>
qc, {
author: LoadEntry<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">
author: prefetch<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>(factory: (input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, ...args: LoadEntryArgs<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>>): LoadEntry<(input: {
id: string;
}) => QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>, "prefetch">
Start the query if the cache has no fresh data, and don't wait for it.
It never makes the load reject: errors show up where a component reads
the handle.
prefetch(function authorQuery(input: {
id: string;
}): QueryOptions<readonly ["authors", string], Author, Author, DefaultTypes>
authorQuery, { id: stringid: const data: LoadedData<{
readonly post: LoadEntry<(input: {
slug: string;
}) => QueryOptions<readonly ["posts", "bySlug", string], Post, Post, DefaultTypes>, "revalidate">;
}>
data.post: Postpost.Post.authorId: stringauthorId }),
});Code splitting
Lazy-loaded route components are another source of waterfalls: the code has to download before it can render and discover what to fetch. Loaders avoid this too. A route's loader is part of the route definition, not the lazy component, so the router runs it in parallel with downloading the component's code.
These docs do the same: each page's demos are separate chunks, and the page's loader starts loading them alongside the page's content instead of after it renders.
Summary
- Start queries in loaders and server components, never in render.
- Start every query a loader needs in one
load. - Shape data so a route's inputs come from the URL, not from other queries.
- Use
waterfallHandleonly when you can't, and treat each one as a known cost.