In June I wrote about streaming as a custom Rust pipeline. A StreamingRenderer ran composition in V8, produced a partial tree plus fallbacks, converted that tree to HTML in Rust, and patched holes with the $RC swap React uses internally. It ran rari.build but Fizz wasn't writing the HTML.

The first chunk waited on V8 composition and a hand-rolled HTML converter. One isolate owned every stream. HTML came from rari's serializer, so the document and the Flight tree could drift. Actions used a custom RPC and a separate /_rari/form-action endpoint. I described that stack in Building rari with rari. The cached homepage path was already fast. Streaming used a second renderer that looked like React's but wasn't React's.

rari 0.15 put official Flight and official Fizz on the Rust + V8 host. After that I stopped holding the first byte until V8 finished an HTML string. Then I ran the shared /stream bench.

How I measured this

The suite in rari-build/benchmarks hits identical /stream routes in the rari and Next.js apps. Each page renders ten Suspense cards on timers: five at 100ms, three at 500ms, two at 1000ms. The profiler does five runs and records TTFB, first content, last byte, resolved cards, and HTTP frames. Then oha runs 15 seconds at 25 connections for throughput.

The homepage bench has no loading.tsx, so rari serves the pre-compressed response cache after the first render. /stream has to do real Fizz work on every request.

MetricrariNext.js
TTFB1ms12ms
First content107ms108ms
Last byte1007ms1009ms
Resolved cards10/1010/10
HTTP frames1210
Throughput (15s, 25 conn)24.99 req/s24.99 req/s

TTFB is 11.1x faster. First content is a wash. Last byte and throughput match because both runtimes wait on the same promises. Each request is held open for ~1s by the card delays, so 25 connections top out at ~25 req/s. 24.99 on both sides is that ceiling.

Official Flight and Fizz didn't break the host

0.15 put React's own stack in the isolate. Flight serializes the RSC tree. Fizz turns that tree into HTML, including the document and Suspense holes. Rust writes the TTFB <!DOCTYPE html> shell. Server actions moved onto the Flight { a, f, q, i } path.

The swap didn't blow up. The host was already driving compose to bytes. Official Flight and Fizz dropped into that slot. Document HTML and the client tree finally came from the same renderer. First byte still waited on the first HTML string.

The HTTP shell

Axum flushes the shell before Fizz has HTML. The chunked response yields <!DOCTYPE html> before composition finishes, and streaming HTML goes out as identity encoding so compressor setup doesn't stall the shell. RSC navigation (text/x-component) still compresses. Metadata flushes when it's ready. After large late chunks, the writer waits about 500µs to coalesce the Flight/complete tail.

TTFB is 1ms because the shell leaves before V8 finishes the first HTML string. The cards still wait ~1000ms; that's the fixture.

Scroll to zoom • Drag to pan

What's left

Rust↔JS hops are still there, and this path has no CDN in front of the process. Cache-hit SSR on static routes is a different story: 0.12ms / ~89k RPS. Streaming cache support and better cold-render performance are the next things I want to spend time on.

You can reproduce the run:

# https://github.com/rari-build/benchmarks
just benchmark-all
# or just streamtest

Same apps, same /stream route, same 15s streaming throughput run. Methodology lives in that repo.