Streaming
Send the HTML shell immediately and stream deferred fragments inline in the same HTTP response. One connection, progressive delivery: no client-side fetch logic required.
When to stream#
Streaming is the right choice when:
- Time-to-first-byte matters (the shell paints before slow queries finish)
- Fragments depend on request-time data (user-specific content, live results)
- You want a single HTTP connection from shell through last fragment
- The client has no JS runtime (or you want to minimise it)
If your content is known at build time and you want zero server infrastructure, use Static generation instead.
renderToStream#
Returns a ReadableStream<string>. The adapter is required: pass a streaming
adapter (e.g. NativeAdapter) as the second argument. NativeAdapter injects
its small polyfill only when fragments are present.
import { Slot, Defer, renderToStream } from "@vincle/flow";import { NativeAdapter } from "@vincle/flow/adapters";
declare function fetchComments(): Promise<{ text: string }[]>;
async function Comments() { const items = await fetchComments(); return ( <ul> {items.map((c) => ( <li>{c.text}</li> ))} </ul> );}
// <Slot> declares the placeholder with fallback content in the shell.// <Defer> pushes the real content, which replaces it when resolved.function Page() { return ( <html> <body> <h1>My page</h1> <Slot name="comments"> <p>Loading comments…</p> </Slot> <Defer target="comments">{() => <Comments />}</Defer> </body> </html> );}
const stream = renderToStream(() => <Page />, NativeAdapter);The shell is rendered fully, then sent as a single chunk. Each fragment follows
as soon as it resolves. renderToStream returns the stream only: headers are
yours to set, or use serve(), which sets them.
To send the stream over HTTP, with serve() or a Node.js server (Express,
Fastify, Hono, anything built on node:http), see
HTTP serving.
Async iterables: streaming sequences#
A <Defer> whose content is an AsyncIterable streams one patch per yielded
item, until the iterable ends or the client disconnects. Every patch carries the
<Defer>'s merge; for a different merge per chunk, use separate <Defer>s.
import { Defer } from "@vincle/flow";
declare function fetchRows(page: number): Promise<{ id: number; name: string }[]>;
function Feed() { return ( <Defer target="feed" merge="append"> {async function* () { let page = 1; while (true) { const rows = await fetchRows(page++); if (rows.length === 0) break; for (const row of rows) { yield <tr key={row.id}>{row.name}</tr>; } } }} </Defer> );}The source need not be a fixed dataset: anything adaptable into an
AsyncIterable works, including a live event source. Node's events.on turns
an EventEmitter into one; pass it the factory's signal (the request's abort
signal combined with the fragment's timeout) so the listener stops when either
fires:
import { Defer } from "@vincle/flow";import { on, type EventEmitter } from "node:events";
declare const priceFeed: EventEmitter; // emits "price" with { symbol, value }
function LivePrice() { return ( <Defer target="price"> {async function* (signal) { for await (const [price] of on(priceFeed, "price", { signal })) { yield ( <span> {price.symbol}: {price.value} </span> ); } }} </Defer> );}