---
title: Streaming SSR
description: Flush the page shell first and stream slow sections as they resolve.
keywords: [Suspense, streaming, out-of-order]
---

Streaming sends the parts of a page that are ready before the parts that are not. The reader sees the shell immediately; a slow section arrives when its data resolves.

Wrap the slow part in `Suspense` and give it a fallback:

```rust filename="src/pages/product.rs"
html! {
    <main>
        <h1>{self.product.name}</h1>
        {Suspense::new(
            Reviews { id: self.product.id },
            html_sync! { <div class="skeleton" /> },
        )}
    </main>
}
```

Ready shell bytes can flush while the boundary remains pending. If the child suspends in out-of-order mode, the fallback appears until its content arrives. Managed documents first complete their metadata preparation.

## When streaming helps

Streaming can reduce time to the first useful content while independent work continues. It helps when one section is meaningfully slower than the rest of the page, and hurts when you split a page that was already fast.

| Situation | Do this |
|---|---|
| One slow query, rest of the page is cheap | Wrap the slow section in `Suspense` |
| Every section is slow | Fix the data layer first; streaming will not hide it |
| The whole page is fast | Prefer a buffered response; streaming adds scheduling and framing work. |
| The consumer does not execute JavaScript | Use buffered or in-order HTML so resolved content is available without swaps. |

With managed metadata enabled, Proa runs layout and Page head hooks before publishing the response head. Make status, cookie, redirect, and cache decisions in the handler or those hooks. Publication requires both the sealed Page metadata and the head outlet; body work streams afterward. Manual streaming documents commit headers before rendering begins. See [Metadata and streaming](/docs/framework/metadata#metadata-and-streaming) and [Status, redirects, and errors](/docs/framework/errors).

## Returning a streaming response

Use the framework response helpers rather than driving the stream yourself:

```rust filename="src/pages/product.rs"
use proa::axum::RouteResponse;

RouteResponse::ssr_stream(ProductPage { id })
```

The framework installs the stream recorder and the chunk flusher, then renders the root through them. [Route responses](/docs/framework/responses) covers the full set of constructors.

## In-order and out-of-order

Two boundaries on one page, one slow and one fast:

```rust filename="src/pages/product.rs"
html! {
    <main>
        <h1>"Product"</h1>
        {Suspense::new(Reviews { id }, html_sync! {
            <p>"loading reviews"</p>
        })}
        {Suspense::new(Stock { id }, html_sync! {
            <p>"loading stock"</p>
        })}
    </main>
}
```

Reviews takes 40 ms and comes first in the document. Stock takes 5 ms and comes second. The two modes resolve that conflict differently:

```text
time ─────────────────────────────────────────────────────────────►
                 0 ms            5 ms                        40 ms

out-of-order     shell           stock                       reviews
                 flushed         sent the moment             sent when it
                                 it resolves                 resolves

in-order         shell                                       reviews, stock
                 flushed                                     stock was ready at
                                                             5 ms and waited
```

Out-of-order sends each boundary as it resolves. In-order preserves document order, so a slow boundary holds up every boundary behind it.

That difference is visible on the wire. Out-of-order writes the fallback between comment markers, then sends each boundary later as a `<template>` plus a one-line swap call:

```html filename="out-of-order (default)"
<main><h1>Product</h1><!--proa:start:1--><p>loading reviews</p><!--proa:end:1--><!--proa:start:2--><p>loading stock</p><!--proa:end:2--></main>
<script>/* $proa swap runtime, sent once */</script>
<template id="proa-tpl-2" data-proa-encoding="base64-v1">…stock…</template><script>$proa(2);</script>
<template id="proa-tpl-1" data-proa-encoding="base64-v1">…reviews…</template><script>$proa(1);</script>
```

Boundary `2` arrives before boundary `1`: stock was second in the document and first on the wire. The swap script replaces the marked region and dispatches `proa:boundary-ready`.

In-order writes the resolved content inline, in document order, and emits nothing else:

```html filename="in-order"
<main><h1>Product</h1><section>reviews</section><section>stock</section></main>
```

No markers, no templates, no script. That is the whole response.

| | In-order | Out-of-order |
|---|---|---|
| Arrival | Document order | Readiness order |
| A slow boundary | Blocks the ones behind it | Blocks nothing |
| Client JavaScript | None | One small swap script |
| `priority` and `timeout` | Ignored | Honored |
| Output equals the buffered render | Yes | Yes, after the swaps run |

Out-of-order is the default for `ssr_stream`. Ask for the other one explicitly:

```rust filename="src/pages/product.rs"
use proa::RenderMode;

RouteResponse::ssr_stream(ProductPage { id })
    .with_render_mode(RenderMode::ChunkedInOrder)
```

Choose in-order when the reader must not depend on JavaScript, or when the response is consumed by something that reads bytes in order rather than executing a document. Choose out-of-order, the default, for a browser page where one section is much slower than the rest.

For generated `text/markdown`, the choice does not arise: Markdown is always in-order, because a swap script cannot run in a text document. `proa::stream::render_md_in_order_to_flusher` drives it. See [Markdown rendering](/docs/core/markdown).

## Priority and timeouts

`Suspense` carries two scheduling options. Both are struct fields:

```rust filename="src/pages/product.rs"
use std::time::Duration;
use proa::Priority;
use proa::stream::Suspense;

Suspense {
    child: Recommendations { id },
    fallback: html_sync! {
        <p>"loading recommendations"</p>
    },
    priority: Priority::Defer,
    timeout: Some(Duration::from_millis(200)),
}
```

`Priority` orders boundaries that become ready in the same turn: `Critical` (the default), then `Optional`, then `Defer`. `timeout` bounds how long the driver waits before it gives up, cancels the future, keeps the fallback on screen, and emits `<!--proa:timeout:N-->` for observability.

Both are out-of-order features. In-order, static, and Markdown renders ignore them by design: reordering would violate their wire contract, and cancelling a child that has already flushed would leave partial markup on the wire.

## Where the data comes from

This page is about delivery. Getting the data into a boundary is [Data loading and streaming](/docs/framework/data-loading), and the two meet at one constructor:

```rust filename="src/pages/product.rs"
RouteResponse::ssr_stream_with_loader(ProductPage { id }, loader)
```

Loading through `WebContext` is what lets independent boundaries overlap instead of running one after another, and request-local dedupe keeps two boundaries that need the same record from fetching it twice. If a page streams but every boundary waits on the same serial query, the data layer is the problem and streaming will not hide it.

## What happens underneath

Three pieces cooperate, and you rarely name any of them directly:

| Piece | Role |
|---|---|
| `Suspense` | Marks the boundary and holds the fallback |
| `RenderBuffer` | Buffers boundary bytes in pages the context owns and recycles |
| `StreamChunkFlusher` | Hands ordered chunks to the transport |

In-order boundaries flush and reuse the parent `RenderBuffer`, so a page with twenty boundaries renders through one recycled buffer. Out-of-order gives each detached boundary its own `RenderBuffer` and flattens it once when the chunk is framed. [Sink](/docs/core/sink) documents the legacy pool-backed writer these replaced.

For manual checkpoint control without a page pool, `StreamingZeroCopyBuf` flushes complete parts and never half-written dynamic content. Normal routes should not manage checkpoints by hand.

> **Good to know**: A reverse proxy can cache a completed streamed HTML response when its policy permits it. Set cache headers before streaming begins, and check the proxy's buffering behavior: cached delivery need not preserve the origin's chunk timing. Loader caching remains useful on cache misses and private routes. See [Page caching](/docs/framework/page-caching).

## Next steps

- [Loading data and streaming](/docs/framework/data-loading)
  - Load request data, apply cache hints, dedupe repeated loads, and stream slow page sections with Suspense.
- [Sink](/docs/core/sink)
  - Migrate deprecated page-pool writers to RenderBuffer.
- [Route responses](/docs/framework/responses)
  - Return synchronous, buffered async, or streaming SSR with islands.
- [Markdown rendering](/docs/core/markdown)
  - Render typed Rust values to Markdown for docs, feeds, and agents.
- [Page caching](/docs/framework/page-caching)
  - Cache public HTML and static assets at browsers and CDNs.
