Streaming: :rf/suspense-boundary¶
You know plain SSR — one drain, one HTML string, one payload. This page is one job: ship a shell on the first byte, then stream slow regions in.
React 18 / Next.js loading.js use <Suspense>; re-frame2 uses one hiccup marker.
Runnable tree:
examples/capabilities/ssr/ssr_streaming/.
Prerequisites. The model and the Ring adapter.
Don't reach for streaming by default
No independently-slow regions → plain ssr-handler is enough. A
:rf/suspense-boundary on the non-streaming emitter fails loud.
Mark slow regions¶
;; Adapted from examples/capabilities/ssr/ssr_streaming/core.cljc
(rf/reg-view ^{:rf/id :article/page} article-page []
[:main.article-page
[:header [:h1 @(rf/subscribe [:article/title])]]
[:div.article-body @(rf/subscribe [:article/body])]
[:section.article-extras
[:rf/suspense-boundary
{:id :region.comments :fallback [:article/comments-skeleton]}
[:article/comments]]
[:rf/suspense-boundary
{:id :region.author-feed :fallback [:article/author-feed-skeleton]}
[:article/author-feed]]]])
The streaming walker emits the shell with each :fallback in place and flushes
immediately — first byte. Each boundary's subtree then streams as its own chunk,
carrying a per-subtree app-db delta so that region's subscriptions see the right
state on arrival.
Failure isolation¶
If one boundary's render throws, that region keeps its fallback (with a
:rf.ssr/suspense-boundary-failed trace) and the rest of the page streams on. A
flaky comments service cannot 500 the whole page — blast radius is one boundary.
Wiring¶
Use the streaming Ring constructor (re-exported on re-frame.ssr.ring):
(require '[re-frame.ssr.ring :as ssr-ring])
(def handler
(ssr-ring/stream-handler
{:initial-events [[:rf/server-init]]
:root-view [:article/page]
:payload [:articles :comments]})) ;; same fail-closed allowlist as ssr-handler
On the client, opt in with ssr/streaming-install! (same carried :frame as
hydrate!) so fallbacks swap for resolved chunks as they arrive.
Correctness lock
Streamed deltas are a speed optimisation. The final chunk is the canonical
full payload — if speculative deltas and the payload disagree, the payload wins.
Streaming latency with a single authoritative :rf/hydrate.
Each boundary :id must be unique
The :id matches a streamed chunk to its placeholder — you pick it (never
autogenerated); it must be stable across the render. Duplicate ids →
:rf.error/suspense-boundary-duplicate-id, last-registered chunk wins, earlier
boundary stuck on fallback (fail-soft, not a 500).
See also¶
- ssr_streaming example
- API: streaming / stream-handler
- Next.js mapping: Coming from Next.js