Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/green-pans-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'spiceflow': patch
---

add a production SSR HTML cache for fast React Server Component pages, keyed by a progressive hash of the flight stream and bounded by total byte size. The cache skips non-GET requests, responses with `Set-Cookie`, and can be disabled with `SPICEFLOW_DISABLE_SSR_CACHE=1` for benchmarking or debugging.
5 changes: 5 additions & 0 deletions .changeset/late-bottles-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'spiceflow': patch
---

add an experimental `SPICEFLOW_SSR_CACHE_MODE=prehash` mode for benchmarking a cache path that hashes the RSC flight stream before SSR, and expand the node benchmark `/about` route so heavier JSX trees make SSR cache tradeoffs easier to measure.
154 changes: 138 additions & 16 deletions nodejs-example/app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,76 @@ import { Link, ProgressBar, redirect } from 'spiceflow/react'
import { sql } from './db'
import { serveStatic } from 'spiceflow'

const aboutStats = [
{ label: 'Server components', value: 'Nested layouts + SSR' },
{ label: 'Runtime targets', value: 'Node, Bun, Workers' },
{ label: 'HTML strategy', value: 'Stream flight + inject payload' },
{ label: 'Benchmark focus', value: 'SSR and hydration cost' },
{ label: 'Data access', value: 'Direct Postgres queries' },
{ label: 'Transport', value: 'Web standard Request/Response' },
] as const

const aboutSections = [
{
title: 'Why this benchmark exists',
body:
'The goal is to measure how much work happens between a React Server Components payload and the final HTML that reaches the browser.',
},
{
title: 'What the page exercises',
body:
'This route intentionally renders a larger tree with repeated cards, nested lists, and descriptive content so the benchmark stresses JSX creation and HTML output more than a tiny static paragraph would.',
},
{
title: 'Why the route stays deterministic',
body:
'The content is static so cache hit rates are easy to reason about and benchmark results are not dominated by random per-request data differences.',
},
{
title: 'What still matters',
body:
'Even with a cache, the shape of the component tree, the cost of SSR, and the amount of HTML written to the socket still affect throughput and latency.',
},
] as const

const aboutFeatureCards = [
'Route matching with nested layouts',
'React Server Components decode on the server',
'HTML stream generation from the flight payload',
'Inline flight payload injection for hydration',
'Redirect and not-found error propagation',
'Header preservation across SSR responses',
'Benchmark toggles for cache modes',
'Byte-bounded LRU caching for HTML output',
'Progressive hashing of the RSC flight stream',
'Streaming fallback when the response is slow',
'Client bootstrap injection with Vite RSC',
'Static asset serving beside the RSC app',
] as const

const aboutFaqs = [
{
question: 'Does this page query the database?',
answer:
'No. The home page does. This route is intentionally deterministic so the benchmark can isolate rendering and caching behavior more clearly.',
},
{
question: 'Why so much markup?',
answer:
'A tiny route makes it hard to see whether HTML-side optimizations matter. A larger tree increases the amount of JSX work and serialized HTML.',
},
{
question: 'Why compare against Next.js and Hono?',
answer:
'They provide useful reference points: plain string HTML on one end and a popular RSC framework on the other.',
},
{
question: 'What should improve with a good cache?',
answer:
'The ideal case is skipping expensive repeated work while preserving the existing streaming behavior for slow pages.',
},
] as const

export const app = new Spiceflow()
.use(serveStatic({ root: './public' }))
.use(serveStatic({ root: './dist/client' })) // required to serve vite built static files
Expand Down Expand Up @@ -40,22 +110,74 @@ export const app = new Spiceflow()
})
.page('/about', async function About() {
return (
<div className="flex flex-col items-center gap-4 p-8 max-w-lg">
<h2 className="text-2xl font-bold">About</h2>
<p className="text-center text-gray-600 dark:text-gray-300">
This is a demo app built with{' '}
<a href="https://github.com/nicedoc/spiceflow" className="underline">
Spiceflow
</a>
, showcasing React Server Components with direct database queries from
the component tree.
</p>
<Link
href="/"
className="mt-2 px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Back to home
</Link>
<div className="flex flex-col gap-8 p-8 max-w-5xl">
<div className="flex flex-col items-center gap-4">
<h2 className="text-2xl font-bold">About</h2>
<p className="max-w-3xl text-center text-gray-600 dark:text-gray-300">
This is a demo app built with{' '}
<a href="https://github.com/nicedoc/spiceflow" className="underline">
Spiceflow
</a>
, showcasing React Server Components with direct database queries from
the component tree. This benchmark version intentionally renders more
JSX so the cost of SSR and HTML generation is easier to observe.
</p>
</div>

<section className="grid grid-cols-2 gap-3 md:grid-cols-3">
{aboutStats.map((stat) => (
<div key={stat.label} className="flex flex-col gap-1 rounded-lg border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-800">
<span className="text-xs uppercase tracking-wide text-gray-500">{stat.label}</span>
<strong className="text-sm text-gray-900 dark:text-gray-100">{stat.value}</strong>
</div>
))}
</section>

<section className="grid gap-4 md:grid-cols-2">
{aboutSections.map((section) => (
<article key={section.title} className="flex flex-col gap-2 rounded-lg border border-gray-300 bg-gray-50 p-5 dark:border-gray-600 dark:bg-gray-900/40">
<h3 className="text-lg font-semibold">{section.title}</h3>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">{section.body}</p>
</article>
))}
</section>

<section className="flex flex-col gap-3">
<h3 className="text-lg font-semibold">Render workload</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{aboutFeatureCards.map((feature, index) => (
<div key={feature} className="flex flex-col gap-2 rounded-lg border border-gray-300 bg-white p-4 dark:border-gray-600 dark:bg-gray-800">
<span className="text-xs uppercase tracking-wide text-gray-500">Step {index + 1}</span>
<strong className="text-sm">{feature}</strong>
<p className="text-sm text-gray-600 dark:text-gray-300">
Repeated card markup increases the amount of JSX the server must
turn into HTML for every request in this benchmark route.
</p>
</div>
))}
</div>
</section>

<section className="flex flex-col gap-3">
<h3 className="text-lg font-semibold">Benchmark FAQ</h3>
<div className="flex flex-col gap-3">
{aboutFaqs.map((faq) => (
<article key={faq.question} className="flex flex-col gap-2 rounded-lg border border-gray-300 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-900/40">
<h4 className="font-medium">{faq.question}</h4>
<p className="text-sm leading-6 text-gray-700 dark:text-gray-300">{faq.answer}</p>
</article>
))}
</div>
</section>

<div className="flex justify-center">
<Link
href="/"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50"
>
Back to home
</Link>
</div>
</div>
)
})
Expand Down
122 changes: 118 additions & 4 deletions nodejs-example/hono-baseline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,132 @@ import { serve } from '@hono/node-server'

const app = new Hono()

const aboutStats = [
{ label: 'Server components', value: 'Nested layouts + SSR' },
{ label: 'Runtime targets', value: 'Node, Bun, Workers' },
{ label: 'HTML strategy', value: 'Stream flight + inject payload' },
{ label: 'Benchmark focus', value: 'SSR and hydration cost' },
{ label: 'Data access', value: 'Direct Postgres queries' },
{ label: 'Transport', value: 'Web standard Request/Response' },
]

const aboutSections = [
{
title: 'Why this benchmark exists',
body:
'The goal is to measure how much work happens between a React Server Components payload and the final HTML that reaches the browser.',
},
{
title: 'What the page exercises',
body:
'This route intentionally renders a larger tree with repeated cards, nested lists, and descriptive content so the benchmark stresses JSX creation and HTML output more than a tiny static paragraph would.',
},
{
title: 'Why the route stays deterministic',
body:
'The content is static so cache hit rates are easy to reason about and benchmark results are not dominated by random per-request data differences.',
},
{
title: 'What still matters',
body:
'Even with a cache, the shape of the component tree, the cost of SSR, and the amount of HTML written to the socket still affect throughput and latency.',
},
]

const aboutFeatureCards = [
'Route matching with nested layouts',
'React Server Components decode on the server',
'HTML stream generation from the flight payload',
'Inline flight payload injection for hydration',
'Redirect and not-found error propagation',
'Header preservation across SSR responses',
'Benchmark toggles for cache modes',
'Byte-bounded LRU caching for HTML output',
'Progressive hashing of the RSC flight stream',
'Streaming fallback when the response is slow',
'Client bootstrap injection with Vite RSC',
'Static asset serving beside the RSC app',
]

const aboutFaqs = [
{
question: 'Does this page query the database?',
answer:
'No. The home page does. This route is intentionally deterministic so the benchmark can isolate rendering and caching behavior more clearly.',
},
{
question: 'Why so much markup?',
answer:
'A tiny route makes it hard to see whether HTML-side optimizations matter. A larger tree increases the amount of JSX work and serialized HTML.',
},
{
question: 'Why compare against Next.js and Hono?',
answer:
'They provide useful reference points: plain string HTML on one end and a popular RSC framework on the other.',
},
{
question: 'What should improve with a good cache?',
answer:
'The ideal case is skipping expensive repeated work while preserving the existing streaming behavior for slow pages.',
},
]

const html = `<!DOCTYPE html>
<html lang="en">
<head><title>About - Hono baseline</title></head>
<body>
<main style="display:flex;flex-direction:column;align-items:center;padding:2rem">
<main style="display:flex;flex-direction:column;align-items:center;padding:2rem;gap:2rem">
<h1>How is this not illegal?</h1>
<div style="display:flex;flex-direction:column;align-items:center;gap:1rem;padding:2rem;max-width:32rem">
<div style="display:flex;flex-direction:column;align-items:center;gap:1rem;padding:2rem;max-width:72rem">
<h2>About</h2>
<p style="text-align:center;color:#666">
<p style="text-align:center;color:#666;line-height:1.7;max-width:48rem">
This is a demo app built with Spiceflow, showcasing React Server Components
with direct database queries from the component tree.
with direct database queries from the component tree. This benchmark version
intentionally renders more JSX so the cost of SSR and HTML generation is
easier to observe.
</p>
<section style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0.75rem;width:100%">
${aboutStats.map((stat) => `
<div style="display:flex;flex-direction:column;gap:0.25rem;border:1px solid #ccc;border-radius:0.75rem;padding:1rem;background:#fff">
<span style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.08em;color:#666">${stat.label}</span>
<strong style="font-size:0.9rem">${stat.value}</strong>
</div>
`).join('')}
</section>
<section style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;width:100%">
${aboutSections.map((section) => `
<article style="display:flex;flex-direction:column;gap:0.5rem;border:1px solid #ddd;border-radius:0.75rem;padding:1.25rem;background:#f8f8f8">
<h3 style="font-size:1.1rem;font-weight:600">${section.title}</h3>
<p style="color:#666;line-height:1.7">${section.body}</p>
</article>
`).join('')}
</section>
<section style="display:flex;flex-direction:column;gap:0.75rem;width:100%">
<h3 style="font-size:1.1rem;font-weight:600">Render workload</h3>
<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0.75rem;width:100%">
${aboutFeatureCards.map((feature, index) => `
<div style="display:flex;flex-direction:column;gap:0.5rem;border:1px solid #ccc;border-radius:0.75rem;padding:1rem;background:#fff">
<span style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.08em;color:#666">Step ${index + 1}</span>
<strong style="font-size:0.95rem">${feature}</strong>
<p style="color:#666;line-height:1.6">
Repeated card markup increases the amount of JSX the server must
turn into HTML for every request in this benchmark route.
</p>
</div>
`).join('')}
</div>
</section>
<section style="display:flex;flex-direction:column;gap:0.75rem;width:100%">
<h3 style="font-size:1.1rem;font-weight:600">Benchmark FAQ</h3>
<div style="display:flex;flex-direction:column;gap:0.75rem;width:100%">
${aboutFaqs.map((faq) => `
<article style="display:flex;flex-direction:column;gap:0.5rem;border:1px solid #ddd;border-radius:0.75rem;padding:1rem;background:#f8f8f8">
<h4 style="font-weight:600">${faq.question}</h4>
<p style="color:#666;line-height:1.7">${faq.answer}</p>
</article>
`).join('')}
</div>
</section>
<a href="/" style="padding:0.5rem 1rem;border:1px solid #ccc;border-radius:0.375rem">
Back to home
</a>
Expand Down
Loading
Loading