Skip to content
Back to skills

Streaming Ssr

ASecurity

SSR speed — routes that block TTFB on the slowest await; stream the shell behind Suspense / await boundaries. Correctness is ssr-audit.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentsgoshellbashreactvueangularnextjsnodeexpressapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill streaming-ssr --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Streaming Ssr?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Streaming Ssr
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-streaming-ssr/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-streaming-ssr)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: streaming-ssr
description: SSR speed — routes that block TTFB on the slowest await; stream the shell behind Suspense / await boundaries. Correctness is ssr-audit.
kind: skill
pack: frontend
allowed-tools: [Read, Grep, Glob, Bash]
---

# streaming-ssr

## Premise

Slow SSR blocks TTFB on the slowest thing the page awaits. If a route `await`s a 600ms query before returning any HTML, the user stares at a white screen for 600ms even though the header, nav, and layout were ready instantly. The fix is almost never "make the query faster" first — it's **stream the shell now, stream the slow subtree later** behind a boundary, so above-the-fold paints immediately and the slow region fills in.

`ssr-audit` checks SSR *correctness* (hydration mismatches). This skill checks SSR *speed*: it turns the open question "is hydration streaming?" into a real detector + fix. `bundle-perf` *(performance pack, when co-installed)* is where that question is normally raised; when that pack is absent nothing else in the project asks it, which is precisely why this skill has its own TTFB trigger below rather than waiting to be handed a finding. Every finding cites the blocking call at `<file:line>` + its observed/measured latency + the proposed boundary + the expected TTFB delta. A streaming recommendation without the cited blocking call is a halt.

**Closure verbs** — exactly one per finding. This skill emits `report-with-fix` (a boundary that is a mechanical edit — wrap the subtree, un-await the loader field, add `loading.tsx`), `report-flagged` (detector 5: enabling Cache Components / partial prerendering is a config change plus a boundary map, so it names the decision and the subtrees rather than editing), `dismiss` (slowest call under ~100ms, or all data above the fold — the § gotchas carve-out), and `halt-handoff` (upstream endpoint latency → the backend owner; hydration mismatch → `ssr-audit`; LCP priority → `lcp-audit`).

## Adapt to the codebase

Mirror the streaming primitive the project already has; never introduce a second one. Detect the framework + its render entry before proposing a boundary.

| Stack | Streaming boundary primitive | Segment-level shell | Un-awaited / deferred data |
|---|---|---|---|
| **Next (App Router)** | `<Suspense fallback>` around the slow subtree | `loading.tsx` beside the segment (auto-wraps in Suspense) | pass the un-awaited promise down; `use()` in the child |
| **Nuxt** | `<Suspense>` / `<NuxtIsland>` / `.server.vue` | `<NuxtLoadingIndicator>` + page-level fallback | `useLazyFetch` / `useFetch(url, { lazy: true })` |
| **SvelteKit** | `{#await}` in `+page.svelte` | `+layout.svelte` shell renders first | return an **un-awaited** promise from `load` |
| **Remix / React Router** | `<Await>` + `<Suspense fallback>` | the parent route's layout renders while the child's promise streams. `HydrateFallback` is **not** a stream shell: it is what SSR renders for a route whose `clientLoader.hydrate` runs only in the browser | un-awaited promise in the loader return |
| **Angular (SSR)** | none: Angular SSR does not stream a deferred region into the same response. On the server a `@defer` block renders its `@placeholder` (or nothing) and its triggers never fire; with incremental hydration and `hydrate` triggers it renders the main content instead (angular.dev, *Deferred loading with @defer*, fetched 2026-10-05) | route-level shell component | the TTFB lever is a resolver or `await` that blocks render; move non-critical data to the client behind `@defer` + `@placeholder`, and report the trade (content absent from the server HTML) |
| **Astro** | `server:defer` island + slot fallback | static shell is the default | island resolves after the shell flushes |
| **Plain React SSR** | `<Suspense>` + `renderToPipeableStream` (`onShellReady`) | shell = everything outside the boundary | promise passed to a `use()` child |

If the render entry (`renderToString` vs `renderToPipeableStream` vs the framework's own server) is not extracted, halt: the fix differs per entry and a boundary added to a buffering renderer changes nothing.

## Pre-flight greps

Run as **one** script from the repo root; shell state does not survive between tool calls. Grep **N** feeds detector **§N**. Every `rg` names its path: given no path and a piped stdin, `rg` reads stdin and reports zero hits. Files-without-match is `--files-without-match`, never `-L` (that is `--follow` in ripgrep). An `rg` error on stderr (exit 2) is a failed grep, not zero hits.

```bash
ROOTS=( $(rg -l --pcre2 '"(?:react|react-dom|vue|svelte|@angular/core|solid-js|preact|astro|next|nuxt)"\s*:' -g '**/package.json' -g '!**/node_modules/**' . | xargs -n1 dirname | sort -u) ); [ ${#ROOTS[@]} -eq 0 ] && ROOTS=(.)
X=(-g '!**/node_modules/**' -g '!**/{dist,build,out,coverage,.next,.nuxt,.output,.svelte-kit,.angular,storybook-static}/**' -g '!**/*.min.*'
   -g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,__fixtures__,stories,playwright,cypress,locales,i18n,generated,__generated__,docs}/**'
   -g '!**/*.{test,spec,e2e,stories,story,mock}.*' -g '!*.{md,mdx,snap,map,lock}' -g '!ai/**')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#|<!--|\{/\*)')
UI=(-g '*.{js,jsx,ts,tsx,mjs,vue,svelte,astro,html}')
PAGES=(-g '**/app/**/page.{tsx,jsx,ts,js}' -g '**/app/**/layout.{tsx,jsx,ts,js}')
# 0. Lanes, majors and the render entry: Next / Nuxt / SvelteKit / React Router / Remix / Angular SSR / Astro, and a hand-rolled server.
rg -n --no-heading '"(next|nuxt|@sveltejs/kit|react-router|@react-router/dev|@remix-run/react|@angular/ssr|@angular/platform-server|astro|react-dom)"\s*:' -g '**/package.json' -g '!**/node_modules/**' "${ROOTS[@]}"
# 1. Whole-page await: an async App Router page/layout that awaits and imports no Suspense (1a); a Remix/RR route module whose loader file awaits twice or more (1b, file-level: read the loader).
rg -l --pcre2 '^export\s+default\s+async\s+function' "${PAGES[@]}" "${X[@]}" "${ROOTS[@]}" | xargs -r rg -l '\bawait\b' | xargs -r rg --files-without-match '\bSuspense\b'
rg -l --pcre2 '^export\s+(?:async\s+)?function\s+loader\b|^export\s+const\s+loader\s*=' -g '**/{app,src}/**/routes/**/*.{tsx,jsx,ts,js}' -g '**/app/root.{tsx,jsx}' "${X[@]}" "${ROOTS[@]}" | xargs -r rg -c '\bawait\b' | awk -F: '$NF >= 2'
# 2. App Router segments whose page awaits and that have no loading.* beside them (a sibling or ancestor loading file also covers it — read).
rg -l --pcre2 '\bawait\b' -g '**/app/**/page.{tsx,jsx,ts,js}' "${X[@]}" "${ROOTS[@]}" | while read -r f; do d=$(dirname "$f"); ls "$d"/loading.* >/dev/null 2>&1 || echo "$f"; done
# 3. Awaited-everything loaders: SvelteKit load returning two awaited fields (3a); Nuxt pages awaiting useFetch/useAsyncData (3b; non-critical data wants lazy).
rg -n -U --no-heading --pcre2 'return\s*\{[^}]*\bawait\b[^}]*\bawait\b' -g '**/+{page,layout}{.server,}.{ts,js}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\bawait\s+(?:useFetch|useAsyncData)\s*\(' -g '**/{pages,app/pages,layouts,app/layouts}/**/*.vue' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 4. A buffering React render entry in server code.
rg -n --no-heading --pcre2 '\brenderToString\s*\(|\brenderToStaticMarkup\s*\(' -g '*.{ts,tsx,js,jsx,mjs,cjs}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
# 5. Partial-prerender candidates and removed APIs: force-dynamic routes (5a); experimental_ppr / ppr config, removed in Next 16 (5b); cacheComponents on (5c).
rg -n --no-heading --pcre2 '\bdynamic\s*=\s*["\x27]force-dynamic["\x27]' "${PAGES[@]}" "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\bexperimental_ppr\b|\bppr\s*:' -g '*.{ts,tsx,js,jsx,mjs}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
rg -n --no-heading --pcre2 '\bcacheComponents\s*:\s*true|["\x27]use cache["\x27]' -g '*.{ts,tsx,js,jsx,mjs}' "${X[@]}" "${ROOTS[@]}" | "${NC[@]}"
```

A hit is a candidate; the finding needs the blocking call's measured latency (Premise). Two blind spots are structural:

- **The slow call hides behind a helper.** `await getProduct()` is one line in the page and three sequential queries in the service. Greps 1 and 3 find the await; the read follows it to the slowest call and the latency comes from the server timing or a log.
- **Grep 2 checks the sibling only.** A `loading.*` in an ancestor segment also wraps the page; read the tree before calling a segment uncovered.

**Measured** (2026-10-05, read-only). Scratch fixture: an awaiting App Router page with no Suspense and no `loading.tsx`, a SvelteKit load returning two awaited fields, a two-await loader, a `renderToString` entry and a `force-dynamic` route, all hit; the page with `<Suspense>` and a sibling `loading.tsx` stays out of 1 and 2. Benchmark fixtures: 0 on greps 1–5 (none ships an SSR route). Real repos: a Nuxt 4 storefront (3b: 1 policy page awaiting `useAsyncData` at the top of setup), a Vue 3 + Vite SPA and an Angular 19 template (0 everywhere: no SSR entry, so every class prints N/A). The first loader grep was a single multi-line PCRE2 pattern that `rg` refused to compile ("regular expression is too large"), which reads as zero hits; it is now file-level.

## The 3,000-hit path

1. **Intersect with the route table** and the server timing: a route whose TTFB is under ~200 ms is dismissed whatever it awaits.
2. **Group** by (layout or loader, pattern). A root layout that awaits a session lookup is one finding for every route under it.
3. **Rank** by route traffic, then by measured TTFB.
4. **Read the top 30 groups.** Ledger the rest as `Live, unreviewed (k groups / n hits)` with the command that continues them, never `checked`.

## Scans for

### 1. Whole-page await before first byte

A page/route that awaits a slow call at the top, with nothing streamed, blocks the entire document.

```
BAD (Next App Router):
export default async function Page() {
  const reviews = await getReviews(id);   // 600ms — blocks the whole shell
  return <><Header/><Product/><Reviews data={reviews}/></>;
}

GOOD:
export default async function Page() {
  return <><Header/><Product/>
    <Suspense fallback={<ReviewsSkeleton/>}>
      <Reviews id={id} />     {/* awaits inside; streams in when ready */}
    </Suspense></>;
}
```

Grep: a top-level `await` on a `fetch(`/`db.`/known-slow call inside a `page.tsx` / `+page.server` `load` / route component, with **no** `<Suspense>` (React/Vue) / `{#await}` (Svelte) / `<Await>` (Remix/RR) wrapping the slow subtree.

### 2. Missing `loading.tsx` on a slow App-Router segment

Next App Router auto-wraps a segment in `<Suspense>` when a sibling `loading.tsx` exists — instant streamed fallback for free.

```
BAD:  app/products/page.tsx awaits a slow fetch, no app/products/loading.tsx
GOOD: app/products/loading.tsx → streamed skeleton; page streams in behind it
```

Flag route dirs with a slow server fetch and no `loading.tsx`.

### 3. Awaited-everything in SvelteKit / Nuxt loaders

```
BAD (SvelteKit load):  return { post: await getPost(), comments: await getComments() };
GOOD:                   return { post: await getPost(), comments: getComments() };  // un-awaited → streamed
                        // consume in +page.svelte: {#await data.comments}…{:then c}…{/await}
```

For Nuxt: a static-but-slow subtree that ships client JS unnecessarily → render it as a server component (`.server.vue` / `<NuxtIsland>`); non-critical data → `useLazyFetch` / `useFetch(url, { lazy: true })` so it doesn't block navigation.

### 4. Blocking `renderToString` in a custom SSR server

```
BAD:   const html = renderToString(<App/>);  res.send(html);   // buffers entire tree
GOOD (Node):  renderToPipeableStream(<App/>, { onShellReady() { res.statusCode=200; pipe(res); } });
GOOD (edge/web): renderToReadableStream(<App/>, { … });        // returns a stream
```

`renderToString` is the blocking baseline — flag it in any hand-rolled SSR entry; propose the streaming variant (`onShellReady` flushes the shell, `onAllReady` for crawlers/SSG).

### 5. Cache Components / partial-prerender candidate (Next 16+)

A route marked fully dynamic (`export const dynamic = 'force-dynamic'`) that has a large static header/footer is a partial-prerender candidate: the static shell prerenders + serves instantly, and only the dynamic holes (anything reading `cookies()` / `headers()` / `searchParams`) stream.

**Read the installed major before emitting anything — this skill runs against repos on both, and the enable line is not the same line:**

```
Next 16+ : cacheComponents: true in next.config.ts, then mark the cacheable shell with the
           "use cache" directive; wrap each dynamic hole in <Suspense>.
Next 15  : export const experimental_ppr = true (+ experimental: { ppr: 'incremental' }).
           BOTH were REMOVED in Next 16 — the config flag and the route-level export.
           Emitting either against a 16+ project produces a build failure, not a warning.
```

Source: the Next.js 16 release notes list `experimental.ppr` and `export const experimental_ppr` in the Removals table, replaced by the Cache Components model (nextjs.org/blog/next-16, published 2025-10-21).

Flag `dynamic = 'force-dynamic'` routes with a large static region as `report-flagged` — the enable is a config + boundary decision, not a mechanical edit.

## Output

```
streaming-ssr audit — <route set>

Findings: 2

1. app/products/[id]/page.tsx:6                        TTFB ~640ms    [report-with-fix]
   Top-level `await getReviews(id)` blocks the shell on a 600ms query.
   Boundary: wrap <Reviews/> in <Suspense fallback={<ReviewsSkeleton/>}>;
             add app/products/[id]/loading.tsx for the segment shell.
   Expected: TTFB 640ms → ~90ms (shell), reviews stream in at ~640ms.

2. src/routes/feed/+page.server.ts:12                  TTFB ~480ms    [report-with-fix]
   `comments: await getComments()` awaited alongside critical `post`.
   Boundary: return un-awaited `comments: getComments()`; consume via {#await}.
   Expected: TTFB blocked on getPost() only (~120ms); comments stream.

Verification: 6 candidates → 2 findings · 1 unconfirmed · 3 refuted

Class coverage:
  §1  whole-page await          checked (3 files → 1 finding)
  §2  no loading.tsx            checked (2 segments → folded into finding 1; 1 covered by an ancestor)
  §3  awaited-everything loader checked (1 hit → 1 finding)
  §4  renderToString entry      N/A (framework server; no hand-rolled entry)
  §5  Cache Components          checked (1 force-dynamic route → 1 unconfirmed: report-flagged, needs the boundary map)
```

`[report-with-fix]`: the blocking call is cited with a measured latency and the boundary is a mechanical edit. `[report-flagged]`: §5, or any row whose latency was not measured; the row names the run that settles it. The coverage block lists **all five** classes on every run: `checked (k hits → m findings)`, `N/A (<reason>)`, or `blocked (<reason>)` when a grep failed. A partial review reads `Live, unreviewed (k groups / n hits)`, never `checked`.

## False positives / gotchas

- **Above-the-fold critical data stays in the shell.** Streaming the hero/LCP content defers LCP — only stream below-the-fold / secondary regions.
- **Auth + redirect decisions must resolve before the shell flushes.** You cannot stream a 200 shell and then decide the user should have been redirected — resolve auth synchronously first.
- Streaming adds boundary overhead; a route whose slowest call is <100ms or whose data is all above-the-fold gains little → `dismiss`.
- A streamed boundary needs a layout-stable fallback (reserve dimensions) or you trade TTFB for CLS — coordinate with `lcp-audit` / `navigation-speed`.
- TTFB dominated by *backend endpoint latency* (the API itself is slow) is not a streaming problem — streaming hides server think-time of the page's own render, it doesn't speed up a slow upstream. Route it to `profile-perf` *(performance pack, when co-installed)*; absent that pack, hand the endpoint to the backend owner with the measured latency attached and record `upstream latency: routed out of scope` — do not propose a boundary that cannot help.

## When to run

- Any SSR route with TTFB > 200ms, or where one query measurably dominates the render.
- After `bundle-perf` / `web-vitals-field` *(performance pack, when co-installed)* attributes LCP to a high `timeToFirstByte` on an SSR route. Absent that pack, the trigger is the route's own measured TTFB above.
- When adding a slow data dependency (reviews, recommendations, related items) to an existing fast route.

## Halt conditions

- Halt on any streaming recommendation that doesn't cite the blocking call at `<file:line>` + its observed/measured latency.
- Halt if the proposed boundary would stream above-the-fold / LCP content (defers LCP) — re-scope to secondary regions.
- Halt if a redirect/auth gate sits behind the proposed streamed shell — resolve it before the flush.
- Halt if "enable partial prerendering / Cache Components" is proposed without naming which subtrees become the dynamic (Suspense-wrapped) holes.
- Halt before reporting any candidate that has not been through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) (§1 restate … §9) and [`precedents/performance.md`](../../../../snippets/precedents/performance.md) § 90 (dev-server numbers are meaningless). **Proof:** server TTFB (`server-response-time`, median of 5 runs) and the time the shell's first byte arrives on a production build, before and after; the streamed region must still render ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § "Lighthouse median").
- Halt if a streaming fix is proposed for Angular SSR — it has no in-response streaming; the trade is client-deferred content, stated as such.
- Halt if the enable line is emitted without reading the framework major from `package.json` — `experimental_ppr` against Next 16+ is a removed API, and a reference that emits a deleted API is worse than no reference.

## Related

- `ssr-audit` — the correctness sibling: it owns hydration mismatches, this skill owns TTFB. A route that streams a mismatched shell is that skill's finding, not this one's.
- `lcp-audit` — a streamed boundary must never contain the LCP element, and its fallback must reserve the box; hand LCP-priority findings there.
- `navigation-speed` — owns prefetch / bfcache / instant-loading on the *client* navigation path; this skill owns the server's first byte.
- `rendering-strategy.md` (ai-pattern) — decides whether the route is server-rendered at all; this skill only places the boundary inside a route that already is.
- `code-splitting.md` (ai-pattern) — the JS axis of the same page; a streamed region whose chunk is eagerly bundled still blocks the main thread.
- `.claude/rules/frontend-principles.md` — the "server-rendered routes MUST stream the shell" MUST this skill enforces.
- Cross-pack (`performance`, when co-installed): `web-vitals-field` measures whether the TTFB win actually reached users. Absent that pack, report the before/after server TTFB only and label it `lab only — field impact unmeasured`; never claim a user-visible win this skill could not observe.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.