# Request waterfalls

> What a request waterfall is, why starting atoms in components causes one, 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 costs a full round trip, and on a slow connection a round trip can take hundreds of milliseconds. Removing waterfalls is usually the most effective thing you can do for a page's loading time.

`raf-effect-atom` is built around this. Route loaders start atoms before anything renders, when everything the page needs is known from the URL, so the requests start together:

```
1. |-> GetIssue
1. |-> GetIssueAssignee
1. |-> ListComments
```

instead of one after another, as each component renders and finds out what it needs:

```
1. |-> GetIssue
2.   |-> GetIssueAssignee
3.     |-> ListComments
```

Below, two copies of a page read the same three atoms. One route's loader starts them; the other has no loader, so each component starts its own atom when it renders, with [`useWaterfall`](#render-time-inputs-usewaterfall).

> [!NOTE]
> The [web version of this page](https://raf-effect-atom.katt.dev/docs/request-waterfalls) has an interactive demo here. Its code: [waterfalls.tsx](https://github.com/KATT/raf-effect-atom/blob/main/www/src/demos/waterfalls.tsx).

## Fetch-on-render

The classic waterfall comes from starting data where it's used. [Effect Atom](https://github.com/Effect-TS/effect/tree/effect%404.0.6/packages/effect/src/reactivity)'s registry runs an atom's effect the first time anything reads it, so a component that reads an atom nothing started starts the request itself. It suspends, and its children, which read their own atoms, render only once its result is in:

```tsx
// Each component builds its atom from an id with Effect Atom's own hook, so
// each read starts its own request.
function IssuePage(props: { issueId: IssueId }) {
	// Starts GetIssue, and suspends until it settles.
	const result = useAtomSuspense(issue(props.issueId));
	return (
		<article>
			<h1>{result.value.title}</h1>
			<Suspense fallback={<p>Loading the assignee…</p>}>
				{/* Renders, and starts GetIssueAssignee, only now. */}
				<Assignee issueId={props.issueId} />
			</Suspense>
		</article>
	);
}

function Assignee(props: { issueId: IssueId }) {
	const result = useAtomSuspense(assignee(props.issueId));
	return <p>{result.value.name}</p>;
}
```

[Suspense](https://react.dev/reference/react/Suspense) makes this worse, not better: a suspended component renders nothing below it, so its children can't even start their requests.

The fix is to start every atom the route reads in its loader and pass the [handles](https://raf-effect-atom.katt.dev/docs/loader-verbs.md#handles) it returns down to the components. A component reads a result that's already on its way, and since it takes a handle instead of an id, it can't start a request of its own:

```tsx title="src/routes/issues.$id.tsx"
export const Route = createFileRoute("/issues/$id")({
	params: {
		parse: (params) => Schema.decodeUnknownSync(IssueParams)(params),
		stringify: Schema.encodeSync(IssueParams),
	},
	loader: async ({ context, params }) => ({
		// Starts the assignee; it streams in while the page renders.
		assignee: context.atoms.prefetch(assignee, params.id),
		// Waits for the issue, so the page renders with it.
		issue: await context.atoms.ensure(issue, params.id),
	}),
	component: () => (
		<IssueBoundary>
			<IssuePage />
		</IssueBoundary>
	),
});
```

Both requests start the moment the navigation does. With the router's [`defaultPreload: "intent"`](https://tanstack.com/router/latest/docs/framework/react/guide/preloading), they start when the user hovers the link.

On the server it matters twice. Only atoms in the registry when the loaders finish are sent to the browser, so an atom first read during render costs a round trip on the server and runs again in the browser.

In development, `useAtomResult` also reports a read that starts an atom nothing started, such as through a handle built by hand: see [Finding waterfalls in development](#finding-waterfalls-in-development).

## Start every atom before awaiting any

Inside a loader, order matters. An `await` before a `prefetch` turns two parallel requests back into a waterfall:

```tsx
// The assignee starts only once the issue is in: two round trips.
const issueFirst = await atoms.ensure(issue, id);
const assigneeLater = atoms.prefetch(assignee, id);
```

Start what streams first, then wait for what the page can't render without:

```tsx
// Both start now; the loader waits only for the issue.
const assigneeHandle = atoms.prefetch(assignee, id);
const issueHandle = await atoms.ensure(issue, id);
```

When the loader needs several results, start them together and await them together:

```tsx
// Both start now, and the loader waits for both.
const [issueLoaded, assigneeLoaded] = await Promise.all([
	atoms.ensure(issue, id),
	atoms.ensure(assignee, id),
]);
```

In Effect code, [`Effect.all`](https://github.com/Effect-TS/effect/blob/effect%404.0.6/packages/effect/src/Effect.ts) runs its effects one after another unless it's given a `concurrency`. Typed failures stay in each result, so the loader still decides tag by tag:

```tsx
// Effect.all runs its effects one after another unless it's given a
// concurrency: without it, the assignee would start after the issue settled.
const both = Effect.all(
	// const both: Effect.Effect<[LoadedHandle<(id: IssueId) => Atom.Atom<AsyncResult.AsyncResult<Issue, RpcClientError | IssueNotFound | Unauthorized>>>, LoadedHandle<(issueId: IssueId) => Atom.Atom<AsyncResult.AsyncResult<User, RpcClientError | IssueNotFound | Unauthorized>>>], never, AtomRegistry>
	[Loader.ensure(issue, id), Loader.ensure(assignee, id)],
	{ concurrency: "unbounded" },
);
```

## Dependent atoms

An atom whose input comes from another atom's result is a waterfall by construction: the loader has to await the first before it can start the second. Fix it where the data is shaped, so every atom a route reads can start from what the URL 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. Links into the page come from places that already know it.
- **Have the backend resolve it.** The server already knows the relations. The example contract's `GetIssueAssignee` takes the issue's id, not the user's, so a loader starts it beside `GetIssue` instead of waiting for the issue's `assigneeId`.

When the data really can't be reshaped, such as a third-party API, you have two options, and both pay the extra round trip:

- **In the loader:** await the parent with `ensure` and start the child from its result, which [`Loader.result`](https://raf-effect-atom.katt.dev/docs/loader-verbs.md#typed-failures-are-results) returns. The navigation waits for the parent, and the child streams in or waits too, as its verb says.
- **In a component:** start the child with [`useWaterfall`](#render-time-inputs-usewaterfall), an escape hatch. The rest of the page renders first.

```tsx
// The input comes from the issue: the user starts a round trip later.
const parent = await atoms.ensure(issue, id);
const found = Loader.result(parent);
const userHandle = AsyncResult.isSuccess(found)
	? atoms.prefetch(user, found.value.assigneeId)
	: undefined;
```

[Shapes that force waterfalls](https://raf-effect-atom.katt.dev/docs/data-shapes.md) goes through the API and URL shapes that cause these chains: an id only another response has, a child that needs its parent's data, a shell that gates its reads, a key that isn't in the URL, one read per item, and data a background job creates. Each comes with its escape hatch and the change that removes it.

## Put the state in the URL

Loaders start what the URL names. The example apps' issue list keeps its status filter and sort order in search params, so its loader starts the matching page, and a filtered list survives a reload or a shared link.

> [!TIP]
> A page number, a filter, the selected item or a dialog that shows data goes in the URL: in search params ([TanStack Router](https://tanstack.com/router/latest/docs/framework/react/guide/search-params), [Next.js](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional), [React Router](https://reactrouter.com/api/hooks/useSearchParams)), or for a dialog, in a route of its own, shown over the page with [route masking](https://tanstack.com/router/latest/docs/framework/react/guide/route-masking) in TanStack Router or [parallel](https://nextjs.org/docs/app/api-reference/file-conventions/parallel-routes#modals) and [intercepting routes](https://nextjs.org/docs/app/api-reference/file-conventions/intercepting-routes) in Next.js. The loader then starts its atoms with the page's, and a link opens it, a reload keeps it and the back button closes it.

### Search as you type

A search box's text is URL state too. Keep it in a search param, start the search in the loader, and have the input replace the param:

```tsx title="src/routes/search.tsx"
export const Route = createFileRoute("/search")({
	validateSearch: Schema.toStandardSchemaV1(
		Schema.Struct({ q: Schema.optional(Schema.String) }),
	),
	loaderDeps: ({ search }) => ({ q: search.q }),
	loader: ({ context, deps }) => ({
		// Starts the search the URL names, if it names one.
		results:
			deps.q === undefined
				? undefined
				: context.atoms.prefetch(searchIssues, deps.q),
	}),
	component: SearchPage,
});

function SearchPage() {
	const search = Route.useSearch();
	const navigate = Route.useNavigate();
	const data = Route.useLoaderData();
	return (
		<search>
			<input
				aria-label="Search issues"
				defaultValue={search.q}
				onChange={(event) => {
					void navigate({
						replace: true,
						search: { q: event.target.value || undefined },
					});
				}}
				type="search"
			/>
			{data.results === undefined ? null : (
				<Suspense fallback={<p>Searching…</p>}>
					<SearchResults results={data.results} />
				</Suspense>
			)}
		</search>
	);
}

function SearchResults(props: { results: Handle<typeof searchIssues> }) {
	// The auth and app boundaries claim Unauthorized and RpcClientError.
	const result = useAuthBoundary(
		// const result: Success<readonly Issue[], never>
		useAtomResult(searchIssues, props.results),
	);
	return (
		<ul>
			{result.value.map((found) => (
				<li key={found.id}>{found.title}</li>
			))}
		</ul>
	);
}
```

Each keystroke navigates, and the loader starts the search for the text the URL holds. To search less often, debounce the [`navigate`](https://tanstack.com/router/latest/docs/framework/react/guide/navigation#usenavigate) call.

## Inputs only the browser has

Some reads need what only the browser has: a one-time token in the URL's fragment, a credential in `sessionStorage`, or a browser API such as [`navigator.permissions`](https://developer.mozilla.org/en-US/docs/Web/API/Permissions/query). A loader on the server can't start them, and starting them from a `useEffect` after hydration waits for that render.

`prefetchInBrowser(source, ...args)` starts them from the same loader as the rest of the page. In the browser it's `prefetch`. On the server it starts nothing and returns a handle marked to start in the browser: the server renders the page around the read and the nearest [Suspense](https://react.dev/reference/react/Suspense) boundary's fallback, and the browser's read starts the atom as it hydrates that boundary:

```tsx
// Only the browser can ask: the atom runs there, so it needs no
// serialization key.
const notifications = Atom.make(
	Effect.promise(() => navigator.permissions.query({ name: "notifications" })),
).pipe(Atom.map(AsyncResult.map((status) => status.state)));

const appRoot = createRootRouteWithContext<{
	readonly atoms: Loader.Loader;
}>()();

const settingsRoute = createRoute({
	getParentRoute: () => appRoot,
	path: "/settings",
	loader: ({ context }) => ({
		// The server renders the page without it; the browser's read starts it.
		notifications: context.atoms.prefetchInBrowser(notifications),
	}),
});

// The page renders on the server; the boundary waits for the browser.
function SettingsPage(props: { notifications: Handle<typeof notifications> }) {
	return (
		<Suspense fallback={<p>Checking notifications…</p>}>
			<NotificationSetting notifications={props.notifications} />
		</Suspense>
	);
}

function NotificationSetting(props: {
	notifications: Handle<typeof notifications>;
}) {
	const result = useAtomResult(notifications, props.notifications);
	return <p>Notifications: {result.value}</p>;
}
```

- The atom reads what only the browser has inside its effect, so it needs no serialization key: the server never starts it, and the arguments the loader passes are what the URL holds.
- On client navigations the loader runs in the browser, so the request starts with the page's other atoms. After a server render it starts once the boundary hydrates, the earliest the browser can, and the waterfall check doesn't report it.
- Read it under a Suspense boundary. The server's read throws, so React renders the boundary's fallback and the browser renders the boundary; with no boundary around it, the server can't render the page. React reports the throw to the server's [`onError`](https://react.dev/reference/react-dom/server/renderToReadableStream#parameters) and the browser's [`onRecoverableError`](https://react.dev/reference/react-dom/client/hydrateRoot#parameters), as for any boundary the server leaves to the browser. A component that renders its own loading state and failures reads the handle with [`useHandleValue`](https://raf-effect-atom.katt.dev/docs/reading-atoms.md#reading-without-suspense) instead: it returns `Initial` on the server, so nothing throws and the page keeps the component's own markup around the read.
- It also keeps out of the HTML a value the server could fetch but shouldn't send, such as a signing secret. The server never starts the atom, so it needs no placeholder there: give the server and the browser the same atom.
- On Next.js, a server component calls `prefetchInBrowser` from `raf-effect-atom/rsc` and passes the handle to a client component. On React Router, loaders call it on `atoms`, like the other verbs.

## Escape hatches

What's left is an input no loader can know: a choice a click or a submit makes that doesn't belong in the URL, or an id only a render finds.

### Event handlers

When a click or a submit picks the data, the handler starts the atom with `Loader.make(registry).prefetch`, which gives it the same verbs a loader has, and keeps the handle in state for the component that reads it. The request starts with the event, before the render that shows it, so it isn't a waterfall.

### Render-time inputs: useWaterfall

When an input exists only in render, such as an id a component reads from another atom's result, no loader or handler can start the atom. `useWaterfall(source, ...args)` starts it during render, like `prefetch`, and returns its handle, so the read looks like one fed by a loader:

```tsx
// The user's id is in the issue, so only a render that has the issue knows it.
function IssueAssignee(props: { issue: Handle<typeof issue> }) {
	const result = useIssueBoundary(useAtomResult(issue, props.issue));
	return (
		<Suspense fallback={<p>Loading the user…</p>}>
			<UserName id={result.value.assigneeId} />
		</Suspense>
	);
}

function UserName(props: { id: UserId }) {
	// The issue, auth and app boundaries claim IssueNotFound, Unauthorized and
	// RpcClientError.
	const result = useIssueBoundary(
		// const result: AsyncResult.Success<User, never>
		useAtomResult(user, useWaterfall(user, props.id)),
	);
	return <p>{result.value.name}</p>;
}
```

It starts the atom only if nothing has, so re-renders don't request it again, and it waits for a result the server is still streaming. Pass it the source a loader would, not an atom derived from it: a derived atom with no serialization key can't be sent from the server.

The name is the warning: each call is a round trip after the render that found the input. Search a codebase for `useWaterfall` and you have a list of its waterfalls. It isn't for typed input, where it would start a request for every value the input passes through: put that [in the URL](#search-as-you-type).

## Finding waterfalls in development

In development, `useAtomResult` checks every read: when the atom it reads has no node in the registry, no hydrated value and no result streaming in, the read is about to start it in render. `WaterfallDetection` in the root layout sets what it does then:

```tsx title="src/routes/__root.tsx"
export const Route = createRootRouteWithContext<{ atoms: Loader.Loader }>()({
	component: () => (
		// In development, a component that starts an atom no loader started throws.
		<WaterfallDetection mode="throw">
			<AppBoundary>
				<AuthBoundary>
					<Outlet />
				</AuthBoundary>
			</AppBoundary>
		</WaterfallDetection>
	),
});
```

| `mode`    | When a read starts an atom nothing started                         |
| --------- | ------------------------------------------------------------------ |
| `"warn"`  | `console.warn` once per atom, naming it. The default.              |
| `"throw"` | Throws to the nearest error boundary, so the page can't be missed. |
| `"off"`   | Nothing.                                                           |

The report names the atom and how to fix it:

```text
raf-effect-atom: rendering read AtomRpc:GetIssueAssignee:1 before anything else had started it, so its request waits for this render: a request waterfall. Start it before render and pass its handle down, from the route's loader or the server component (atoms.prefetch(source, ...args) or atoms.ensure). If the state that picks it, such as a page, a filter or an open dialog, isn't in the URL, put it there so the loader can. As escape hatches for state that can't be in the URL: start it in the event handler that changes the state (Loader.make(registry).prefetch), or, if its input only exists in render, read it with useAtomResult(source, useWaterfall(source, ...args)). https://raf-effect-atom.katt.dev/docs/request-waterfalls
```

Atoms a loader, a server component, an event handler or `useWaterfall` started aren't reported, and neither is a derived atom over one of those. The example apps use `"throw"`, so a component that forgets its loader breaks the page in development instead of shipping a waterfall.

Production builds don't check. The check sits behind `process.env.NODE_ENV !== "production"`, which bundlers replace at build time, so it's left out of the bundle.

## Code splitting

Lazy route components are another waterfall: the code has to download before it renders and finds out what to fetch. A route's loader is part of the route definition, not of the lazy component, so the router runs it while the component's code downloads.

## Summary

- Start atoms in loaders and server components, never in render, and pass their handles to the components that read them.
- Put the state that picks data in the URL: search params, a search box's text included, and routes for dialogs.
- Start every atom before awaiting any, and give `Effect.all` a `concurrency`.
- Shape data so a route's inputs come from the URL, not from other atoms: see [Shapes that force waterfalls](https://raf-effect-atom.katt.dev/docs/data-shapes.md).
- Start reads that need what only the browser has, such as a token in the URL's fragment, with `prefetchInBrowser` in the loader, and read them under Suspense.
- Start an atom a click or a submit picks in its handler, and one whose input only a render finds with `useWaterfall`, a known cost.
- Keep `<WaterfallDetection mode="throw">` in the root layout, so development finds the rest.
