Skip to content

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