Guides: Streaming slow data

Guides

Streaming slow data

A page waits for its load functions before it renders. When one piece of data is slow, a recommendations query or a third-party API, the whole page waits for it. Two tools fix that: promises in load data, and a loading export.

Return the promise

Any top-level property of a load's return value can be a promise. Grimoire sends the page without waiting for it:

src/routes/books/[id]/+page.server.ts
import { error, type LoadContext } from "@sigil-dev/grimoire";
import { getBook, getReviews } from "../../../lib/server/db";

export function load({ params }: LoadContext) {
	const book = getBook(Number(params.id));
	if (!book) throw error(404, "No book with that id.");
	return {
		book,
		// not awaited: the page goes out first
		reviews: getReviews(book.id),
	};
}
src/routes/books/[id]/+page.tsx
interface Review {
	id: number;
	text: string;
}

const BookPage = ({ data }: { data: { book: { title: string }; reviews?: Review[] } }) => (
	<article>
		<h1>{data.book.title}</h1>
		{data.reviews ? (
			<ul>
				{data.reviews.map((r) => (
					<li key={r.id}>{r.text}</li>
				))}
			</ul>
		) : (
			<p>Loading reviews…</p>
		)}
	</article>
);

export default BookPage;

What happens on a full page load:

  1. The server renders the page with data.reviews as undefined, so the fallback shows, and sends it.
  2. When the promise settles, its value goes out in a later chunk of the same response.
  3. Once the response is complete the page hydrates with the real values, and the list replaces the fallback.

The visitor sees the book, and can read and follow links, while the reviews are still on their way. Two details:

  • Only top-level properties stream. A promise nested deeper in the object isn't awaited or streamed, so keep slow values at the top.
  • A promise that rejects arrives as null. Catch inside the load if you want a friendlier value: reviews: getReviews(id).catch(() => []).

Showing a streamed value when it lands

Above, a value in flight renders as undefined and you supply your own fallback. That works, but the value only appears when something else causes a re-render — a click, a navigation, a transition — because the page's state script holds the value and hydration waits for the whole response.

deferred() closes that. It reads the streamed value, tracks the grimoire:deferred event the server dispatches when the value settles, and tells you which state you are in:

src/routes/books/[id]/+page.tsx
import { deferred } from "@sigil-dev/grimoire";

const BookPage = ({ data }: { data: PageData }) => {
	const reviews = deferred(data.reviews, []);

	return (
		<article>
			<h1>{data.book.title}</h1>
			{reviews.done ? (
				<ul>
					{reviews.value.map((r) => (
						<li key={r.id}>{r.text}</li>
					))}
				</ul>
			) : (
				<p>Loading reviews…</p>
			)}
		</article>
	);
};

The value is undefined while loading, exactly as above, so the fallback is yours to write. The difference is that this one knows when to stop showing it.

Three things it handles that a hand-written listener usually gets wrong:

  • A value that lands before the listener is attached. A promise that resolves in a millisecond can patch the state before your component wires up. deferred() reads the state on the way in, so it is not missed.
  • The state element versus the mirror object. The runtime keeps the authoritative copy in a <script> element and a plain object as a fallback for patches that arrive before the element is parsed. They are told apart by nodeType, and reading the wrong one silently loses the value.
  • A rejected promise. It arrives as null, which is not a usable value, so it stays done: false and keeps showing your fallback. Catch it in the load if you would rather show an error: getReviews(id).catch(() => []).

The second argument is the value to read while loading. Anything falsy will do — [], "", null — because undefined is what "still loading" means, so undefined cannot also be the fallback.

Several slow values at once

Each one is independent, and each reports its own state:

const reviews = deferred(data.reviews, []);
const similar = deferred(data.similar, []);
const price = deferred(data.price, null);

They still all arrive in the same chunk, because they settle independently but the response is one stream. deferred() does not change that; it just means the page is correct when the chunk lands.

Pass the key when there is more than one. The event that announces a settled value says that something settled, not which, so with several deferred values on a page the third argument is what keeps them apart — without it the first component to read would take another's data:

const reviews = deferred(data.reviews, [], "reviews");
const similar = deferred(data.similar, [], "similar");

The key is the property name in your load's return value. With a single deferred value on the page you can leave it out.

What streaming is for

Worth being clear about, because the failure mode is a page that feels slow anyway. Streaming helps when the visitor has something to do while the slow part is in flight — read the book, click a link, start on another page. It does not help when the slow part is above the fold and everything else depends on it; that is a query to fix, not a render strategy.

Two rules of thumb:

  • Under ~200ms, stream anyway. It costs nothing and it keeps a slow dependency from becoming the whole page.
  • Over a second, give the visitor something real. A skeleton in the shape of the arriving content beats a spinner, because it means the page is not shifting when the data lands.

Client-side navigation

When the router navigates to a page, it asks the server for the page's data, and the server waits for every promise before answering. Streaming doesn't apply there; a loading export does.

src/routes/books/[id]/+page.tsx (the loading export)
export const loading = () => <p class="loading">Loading…</p>;

A page module that exports loading gets it shown in place of the page (inside the layouts) as soon as a navigation to it starts, until its data arrives. Without one, the old page stays up until the new one is ready.

loading on a full page load

On a full page load, loading also streams: the layouts and the loading state go out first. But only a load exported from the page component module itself (a universal load) runs after that point; +page.server.ts and layout loads have finished before anything is sent, because they can still redirect or fail. For slow server data, return a promise as above.

Full page loadClient-side navigation
Promise in load dataStreams; fills in on hydrationWaited for
loading exportShown while a universal load runsShown until the data arrives

Edit this page