diff --git a/README.md b/README.md
index c721694..8cb27ab 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,8 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b
- React Server Components: server-first rendering, less client JavaScript
- Streaming SSR with Suspense boundaries and progressive hydration
- Suspense-based data fetching instead of scattered loading flags
+- Request-scoped memoization for fetch deduplication (the `React.cache()` pattern)
+- Out-of-order streaming: boundaries flush by resolution time, not tree order
- App Router file conventions (`layout`, `page`, `loading`, `error`)
- Type-safe design tokens as a single origin for color, spacing, and type scale
- Core Web Vitals: LCP, CLS, and INP tracked against a budget
@@ -22,6 +24,7 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b
- Scaffold: Next.js 15 App Router, React 19, strict TypeScript, Vitest + Testing Library, GitHub Actions CI, and a design-token stylesheet. The home route lists the planned concepts using an accessible `ConceptCard` component.
- Design tokens: a typed token tree in `src/tokens` that generates the app's CSS custom properties. The root layout injects the generated `:root` rule at render time, so the token module is the runtime source and there is no second copy to drift. See the `/tokens` route.
+- Server Components + streaming: the `/streaming` route is a Server Component whose three cards are each their own async Server Component behind a Suspense boundary. The shell flushes first, then each card streams in as its own fetch settles, fastest first. Data comes from a small simulated layer built on a request-scoped memoizing loader (the `React.cache()` pattern), so components that read the same key during one render share a single fetch and a rejection is cached the same way. Tests cover the loader dedup, out-of-order settle order, and the async components rendered to static markup. See the `/streaming` route.
## Stack
@@ -51,6 +54,16 @@ import { ConceptCard } from '@/components/concept-card'
/>
```
+Each streaming card is an async Server Component reading from a request-scoped store, wrapped in its own boundary so it streams independently:
+
+```tsx
+const store = createDataStore() // one per request, so the loaders memoize per render
+
+}>
+ // async () =>
+
+```
+
Styles reference tokens through a typed helper, so a bad name fails to compile:
```tsx
diff --git a/app/page.tsx b/app/page.tsx
index 9c27f04..be835e7 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -11,7 +11,8 @@ const CONCEPTS: readonly Concept[] = [
{
title: 'Server Components & streaming',
description: 'Render on the server, stream HTML as it resolves, and ship less JavaScript to the client.',
- status: 'planned'
+ status: 'done',
+ href: '/streaming'
},
{
title: 'Suspense data fetching',
diff --git a/app/streaming/cards.tsx b/app/streaming/cards.tsx
new file mode 100644
index 0000000..ce3efc0
--- /dev/null
+++ b/app/streaming/cards.tsx
@@ -0,0 +1,66 @@
+import { type DataStore } from '@/streaming/data'
+import { token } from '@/tokens'
+
+const cardStyle: React.CSSProperties = {
+ padding: token('space', '6'),
+ border: '1px solid var(--color-border)',
+ borderRadius: token('radius', 'md'),
+ background: 'var(--color-surface)'
+}
+
+export async function ProfileCard({ store, id }: { store: DataStore; id: string }) {
+ const profile = await store.profile(id)
+ return (
+
+ {profile.name}
+ {profile.role}
+
+ )
+}
+
+export async function Recommendations({ store }: { store: DataStore }) {
+ const items = await store.recommendations()
+ return (
+
+ Recommended reading
+
+ {items.map((item) => (
+ - {item.title}
+ ))}
+
+
+ )
+}
+
+export async function ActivityFeed({ store, id }: { store: DataStore; id: string }) {
+ const feed = await store.activity(id)
+ return (
+
+ Recent activity
+
+ {feed.items.map((line) => (
+ - {line}
+ ))}
+
+
+ )
+}
+
+export function Skeleton({ label, lines }: { label: string; lines: number }) {
+ return (
+
+ {Array.from({ length: lines }, (_, i) => (
+
+ ))}
+
+ )
+}
diff --git a/app/streaming/loading.tsx b/app/streaming/loading.tsx
new file mode 100644
index 0000000..f192f2c
--- /dev/null
+++ b/app/streaming/loading.tsx
@@ -0,0 +1,16 @@
+import { token } from '@/tokens'
+
+// Shown while the route segment itself is still resolving on the server. The
+// per-card Suspense fallbacks in page.tsx take over once the shell streams in.
+export default function Loading() {
+ return (
+
+ Loading…
+
+ )
+}
diff --git a/app/streaming/page.tsx b/app/streaming/page.tsx
new file mode 100644
index 0000000..b2bcad2
--- /dev/null
+++ b/app/streaming/page.tsx
@@ -0,0 +1,48 @@
+import { Suspense } from 'react'
+import { createDataStore } from '@/streaming/data'
+import { token } from '@/tokens'
+import { ActivityFeed, ProfileCard, Recommendations, Skeleton } from './cards'
+
+export const metadata = {
+ title: 'Server Components & streaming - Modern Frontend Lab'
+}
+
+const USER_ID = 'u1'
+
+export default function StreamingPage() {
+ // One store per request: the memoizing loaders inside it stay request-scoped.
+ const store = createDataStore()
+
+ return (
+
+ Server Components & streaming
+
+ The page is a Server Component. Each card below is its own async Server Component behind a Suspense boundary, so the
+ shell flushes immediately and every card streams in as its data settles, fastest first. None of these cards ship
+ client JavaScript.
+
+
+
+
}>
+
+
+
}>
+
+
+
}>
+
+
+
+
+
+ Why this shape
+
+ Rendering blocks only where you await. Wrapping each slow read in its own boundary turns one all-or-nothing wait
+ into three independent flushes, so a fast section never waits behind a slow one. The tradeoff is layout shift: a
+ fallback that is not the same height as its content moves the page when it swaps, which hurts CLS. Size the
+ skeletons to match, and do not over-split, since every boundary is a separate round of hydration work.
+
+
+
+ )
+}
diff --git a/src/streaming/data.ts b/src/streaming/data.ts
new file mode 100644
index 0000000..526adcb
--- /dev/null
+++ b/src/streaming/data.ts
@@ -0,0 +1,90 @@
+import { createLoader } from './loader'
+
+export interface Profile {
+ id: string
+ name: string
+ role: string
+}
+
+export interface ActivityFeed {
+ items: readonly string[]
+}
+
+export interface Recommendation {
+ id: string
+ title: string
+}
+
+export class NotFoundError extends Error {
+ constructor(
+ readonly kind: string,
+ readonly key: string
+ ) {
+ super(`${kind} not found: ${key}`)
+ this.name = 'NotFoundError'
+ }
+}
+
+// Latencies are staggered so the route visibly streams out of order: the shell
+// paints, then each boundary flushes as its own fetch settles, fastest first.
+export const LATENCY = {
+ profile: 120,
+ activity: 640,
+ recommendations: 340
+} as const
+
+const PROFILES: Readonly> = {
+ u1: { id: 'u1', name: 'Ada Lovelace', role: 'Analyst' },
+ u2: { id: 'u2', name: 'Grace Hopper', role: 'Compiler engineer' }
+}
+
+const FEEDS: Readonly> = {
+ u1: { items: ['Opened issue #204', 'Merged the notes engine', 'Reviewed 3 PRs'] },
+ u2: { items: ['Shipped the linker', 'Coined "debugging"'] }
+}
+
+const RECOMMENDATIONS: readonly Recommendation[] = [
+ { id: 'r1', title: 'Streaming SSR in depth' },
+ { id: 'r2', title: 'When Suspense stops paying off' },
+ { id: 'r3', title: 'Request-scoped caching patterns' }
+]
+
+export type Wait = (ms: number) => Promise
+
+const realWait: Wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
+
+export interface DataStore {
+ profile(id: string): Promise
+ activity(id: string): Promise
+ recommendations(): Promise
+}
+
+// One store per render pass keeps the memoization request-scoped: a module-level
+// cache would leak one request's data into the next. Tests pass a zero-latency
+// `wait` to stay fast and deterministic without fake timers.
+export function createDataStore(wait: Wait = realWait): DataStore {
+ const profiles = createLoader(async (id) => {
+ await wait(LATENCY.profile)
+ const found = PROFILES[id]
+ if (found === undefined) throw new NotFoundError('profile', id)
+ return found
+ })
+
+ const feeds = createLoader(async (id) => {
+ await wait(LATENCY.activity)
+ const found = FEEDS[id]
+ if (found === undefined) throw new NotFoundError('activity', id)
+ return found
+ })
+
+ const recs = createLoader<'all', readonly Recommendation[]>(async () => {
+ await wait(LATENCY.recommendations)
+ return RECOMMENDATIONS
+ })
+
+ return {
+ profile: (id) => profiles.load(id),
+ activity: (id) => feeds.load(id),
+ recommendations: () => recs.load('all')
+ }
+}
diff --git a/src/streaming/loader.ts b/src/streaming/loader.ts
new file mode 100644
index 0000000..c010909
--- /dev/null
+++ b/src/streaming/loader.ts
@@ -0,0 +1,27 @@
+export type Fetcher = (key: K) => Promise
+
+export interface Loader {
+ load(key: K): Promise
+ keys(): readonly K[]
+}
+
+// Request-scoped memoization, the same idea as React's `cache()`: several server
+// components that ask for the same key during one render share a single fetch.
+// The promise is stored, not the value, so concurrent reads dedupe before the
+// first one has even resolved. A rejected promise stays cached too, matching
+// `cache()` semantics, so a retry inside the same render fails the same way
+// instead of hammering a flaky source.
+export function createLoader(fetcher: Fetcher): Loader {
+ const inflight = new Map>()
+
+ return {
+ load(key) {
+ const existing = inflight.get(key)
+ if (existing !== undefined) return existing
+ const pending = fetcher(key)
+ inflight.set(key, pending)
+ return pending
+ },
+ keys: () => [...inflight.keys()]
+ }
+}
diff --git a/test/data.test.ts b/test/data.test.ts
new file mode 100644
index 0000000..9969ef0
--- /dev/null
+++ b/test/data.test.ts
@@ -0,0 +1,59 @@
+import { createDataStore, LATENCY, NotFoundError, type Wait } from '@/streaming/data'
+
+const instant: Wait = () => Promise.resolve()
+
+describe('createDataStore', () => {
+ it('reads seeded records', async () => {
+ const store = createDataStore(instant)
+ const profile = await store.profile('u1')
+ expect(profile.name).toBe('Ada Lovelace')
+ const feed = await store.activity('u1')
+ expect(feed.items).toHaveLength(3)
+ const recs = await store.recommendations()
+ expect(recs.map((r) => r.id)).toEqual(['r1', 'r2', 'r3'])
+ })
+
+ it('dedupes repeated reads of the same key within one store', async () => {
+ let waits = 0
+ const counting: Wait = async () => {
+ waits += 1
+ }
+ const store = createDataStore(counting)
+
+ await Promise.all([store.profile('u1'), store.profile('u1'), store.recommendations()])
+ // Two distinct resources touched, so exactly two underlying reads.
+ expect(waits).toBe(2)
+ })
+
+ it('does not share cache across stores', async () => {
+ let waits = 0
+ const counting: Wait = async () => {
+ waits += 1
+ }
+ await createDataStore(counting).profile('u1')
+ await createDataStore(counting).profile('u1')
+ expect(waits).toBe(2)
+ })
+
+ it('throws a typed NotFoundError for an unknown key', async () => {
+ const store = createDataStore(instant)
+ await expect(store.profile('nobody')).rejects.toBeInstanceOf(NotFoundError)
+ await expect(store.activity('nobody')).rejects.toThrow('activity not found: nobody')
+ })
+
+ it('settles boundaries fastest-first, which is the streaming order', async () => {
+ vi.useFakeTimers()
+ try {
+ const store = createDataStore()
+ const order: string[] = []
+ store.profile('u1').then(() => order.push('profile'))
+ store.activity('u1').then(() => order.push('activity'))
+ store.recommendations().then(() => order.push('recommendations'))
+
+ await vi.advanceTimersByTimeAsync(Math.max(LATENCY.profile, LATENCY.activity, LATENCY.recommendations))
+ expect(order).toEqual(['profile', 'recommendations', 'activity'])
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+})
diff --git a/test/loader.test.ts b/test/loader.test.ts
new file mode 100644
index 0000000..f3168c6
--- /dev/null
+++ b/test/loader.test.ts
@@ -0,0 +1,62 @@
+import { createLoader } from '@/streaming/loader'
+
+describe('createLoader', () => {
+ it('runs the fetcher once per key and reuses the same promise', () => {
+ const calls: string[] = []
+ const loader = createLoader(async (key) => {
+ calls.push(key)
+ return key.length
+ })
+
+ const a1 = loader.load('ada')
+ const a2 = loader.load('ada')
+
+ expect(a1).toBe(a2)
+ expect(calls).toEqual(['ada'])
+ })
+
+ it('dedupes concurrent reads before the first resolves', async () => {
+ let resolved = 0
+ const loader = createLoader(
+ (key) =>
+ new Promise((resolve) => {
+ queueMicrotask(() => {
+ resolved += 1
+ resolve(key.length)
+ })
+ })
+ )
+
+ const results = await Promise.all([loader.load('grace'), loader.load('grace'), loader.load('grace')])
+
+ expect(results).toEqual([5, 5, 5])
+ expect(resolved).toBe(1)
+ })
+
+ it('keeps distinct keys independent', () => {
+ const loader = createLoader(async (key) => key.length)
+ expect(loader.load('a')).not.toBe(loader.load('bb'))
+ expect(loader.keys()).toEqual(['a', 'bb'])
+ })
+
+ it('memoizes a rejection so a retry in the same pass fails identically', async () => {
+ let attempts = 0
+ const loader = createLoader(async () => {
+ attempts += 1
+ throw new Error('flaky')
+ })
+
+ const first = loader.load('x')
+ const second = loader.load('x')
+
+ expect(first).toBe(second)
+ await expect(first).rejects.toThrow('flaky')
+ await expect(second).rejects.toThrow('flaky')
+ expect(attempts).toBe(1)
+ })
+
+ it('starts empty', () => {
+ const loader = createLoader(async (key) => key.length)
+ expect(loader.keys()).toEqual([])
+ })
+})
diff --git a/test/streaming-components.test.tsx b/test/streaming-components.test.tsx
new file mode 100644
index 0000000..5cd606b
--- /dev/null
+++ b/test/streaming-components.test.tsx
@@ -0,0 +1,43 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import type { ReactElement } from 'react'
+import { ActivityFeed, ProfileCard, Recommendations } from '../app/streaming/cards'
+import { createDataStore, type Wait } from '@/streaming/data'
+
+const instant: Wait = () => Promise.resolve()
+
+// An async Server Component is just `async () => ReactElement`. Awaiting it and
+// rendering the resolved element proves the component fetched and mapped real
+// data, without needing a running Next server.
+async function renderServer(node: Promise): Promise {
+ return renderToStaticMarkup(await node)
+}
+
+describe('streaming server components', () => {
+ it('ProfileCard renders the fetched profile', async () => {
+ const store = createDataStore(instant)
+ const html = await renderServer(ProfileCard({ store, id: 'u1' }))
+ expect(html).toContain('Ada Lovelace')
+ expect(html).toContain('Analyst')
+ })
+
+ it('Recommendations lists every item', async () => {
+ const store = createDataStore(instant)
+ const html = await renderServer(Recommendations({ store }))
+ expect(html).toContain('Streaming SSR in depth')
+ expect((html.match(//g) ?? [])).toHaveLength(3)
+ })
+
+ it('ActivityFeed renders the feed in order', async () => {
+ const store = createDataStore(instant)
+ const html = await renderServer(ActivityFeed({ store, id: 'u1' }))
+ const first = html.indexOf('Opened issue #204')
+ const last = html.indexOf('Reviewed 3 PRs')
+ expect(first).toBeGreaterThan(-1)
+ expect(last).toBeGreaterThan(first)
+ })
+
+ it('propagates a missing-record error to the boundary', async () => {
+ const store = createDataStore(instant)
+ await expect(ProfileCard({ store, id: 'ghost' })).rejects.toThrow('profile not found: ghost')
+ })
+})