
Remix V2 Perf Ssr
- 27 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-perf-ssr is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-perf-ssr
- AI & Agent Building
- AI-coding skill
Remix V2 Perf Ssr by the numbers
- 27 all-time installs (skills.sh)
- Ranked #9,601 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-perf-ssrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Remix v2 Performance, Streaming, Caching, Server/Client Split
Remix v2 has no built-in image optimizer and no opaque framework cache — it pushes everything to the standard HTTP layer. The performance surface is four pillars: streaming (defer/<Await>), HTTP caching (headers export), prefetching (<Link prefetch> and <PrefetchPageLinks>), and a hard server/client split (.server.* / .client.* file conventions).
Quick Reference
`headers` export with SWR (forward loader headers to the document):
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
return json(post, {
headers: {
"Cache-Control":
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});`.server.ts` for server-only modules — build fails loud if the file leaks into the client graph:
// app/lib/db.server.ts — never bundled into the client
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();`defer()` for slow secondary data:
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.getProduct(params.id); // critical, awaited
const reviews = db.getReviews(params.id); // slow, not awaited
return defer({ product, reviews });
}
export default function Product() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
</>
);
}Streaming with defer and <Await>
Every promise passed to defer must be created before any await in the loader, otherwise the loader still blocks on the slow call and streaming gains nothing. Always pair <Await> with errorElement — without it, a rejected deferred promise bubbles to the route's ErrorBoundary and tears down the whole route, defeating the streaming benefit.
See references/streaming.md for full coverage.
HTTP Caching via headers
max-age controls browser cache; s-maxage controls shared/CDN cache and overrides max-age at the CDN; stale-while-revalidate lets the CDN serve stale content while it refreshes in the background. Two cache scopes exist per route: the document response (controlled by the headers export) and the data request (the ?_data= JSON request fired on client-side navigation — controlled by the loader's response headers). They can — and often should — carry different policies.
Parent/child merge is "deepest route wins": only the deepest matched route's headers runs by default. If a child route has no headers export, Remix walks up to the nearest parent that does. The safest rule: define headers only on leaf routes, never on layouts that wrap personalized children. Otherwise an aggressive parent policy silently caches per-user HTML at the CDN.
When merging in a child, pick the smaller max-age — never widen a parent's caching policy from a child:
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
const loader = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parent = parseCacheControl(parentHeaders.get("Cache-Control"));
const maxAge = Math.min(loader["max-age"] ?? 0, parent["max-age"] ?? 0);
return { "Cache-Control": `private, max-age=${maxAge}` };
};See references/headers-caching.md.
Server/Client Split
The compiler strips loader, action, and headers exports from client bundles along with the dependencies used inside them — but only if those dependencies have no module side effects. A top-level new PrismaClient(), a console.log, an initializeApp call all defeat tree-shaking. Rule: any module that imports node:fs, prisma, bcrypt, jsonwebtoken, or reads process.env should be named *.server.ts (or live under app/.server/ — directory form requires the Remix Vite plugin; Classic Compiler supports only the filename suffix). Build fails loud if it reaches the client graph — silent leaks are eliminated.
Public env vars reach the browser via a root-loader window.ENV pattern. Never return raw process.env from a loader. See references/server-client-split.md.
clientLoader and clientAction
v2 added optional clientLoader / clientAction exports that run in the browser alongside (or instead of) the server loader/action. By default clientLoader does NOT run on initial hydration — the server loader SSRs the page, and clientLoader only fires on subsequent client navigations. Opt in to first-render execution with clientLoader.hydrate = true and export a HydrateFallback component to render while it executes:
import type { ClientLoaderFunctionArgs } from "@remix-run/react";
export async function loader() {
return json({ /* SSR data */ });
}
export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
const cached = clientCache.get();
if (cached) return cached;
const fresh = await serverLoader<typeof loader>(); // round-trip to server loader
clientCache.set(fresh);
return fresh;
}
clientLoader.hydrate = true; // opt in to running on initial hydration
export function HydrateFallback() {
return <Skeleton />;
}Use clientLoader for: client-side caching of server payloads, reading from IndexedDB / localStorage after hydration, fully client-only routes (skip loader entirely). Do NOT re-fetch the same server payload SSR'd by the route's loader — that's a wasted round-trip; either call serverLoader() and cache, or only run on transitions (leave hydrate false).
Hydration Safety
useHydrated() returns false during SSR and on the very first client render, then flips to true on the next render — that two-pass behavior is what keeps HTML matched. For components that should never SSR (maps, charts that read window), wrap in <ClientOnly fallback={...}>. For SSR-safe IDs use React's useId(), never Math.random() or crypto.randomUUID() in render.
The hydration-mismatch grep list: new Date(, Math.random(, crypto.randomUUID(, Date.now(, window., document., localStorage, sessionStorage, navigator., Intl.DateTimeFormat() without an explicit locale, Intl.NumberFormat, .toLocaleDateString, .toLocaleTimeString, .toLocaleString, process.env. in component bodies, typeof window ternaries that produce different JSX, third-party scripts that mutate the DOM, browser extensions injecting nodes into <body>. See references/hydration.md.
Prefetching
Four <Link prefetch> modes: "none" (default), "intent" (hover/focus), "render" (immediate, on render), "viewport" (scrolled into view). Prefetch fires <link rel="prefetch"> tags as siblings of the anchor — use :last-of-type in CSS, not :last-child, because the prefetch tags briefly become last child.
A subtle gotcha: hover-prefetch with no Cache-Control on the loader doubles the request count because the browser doesn't cache the prefetch response. Detect the Purpose: prefetch header in the loader and return Cache-Control: private, max-age=10. See references/prefetch.md.
Asset Preloading via links
The links export injects <link> tags into the document head — preload critical fonts and CSS, prefetch likely-next-page assets. Remix has no built-in image optimizer; size images at build time (sharp, unpic, remix-image) and always set width/height. See references/links-preload.md.
Gates (decision sequencing)
Answer in order. Pass means the condition is true; pick the API on the same line and stop.
defer() vs awaiting in the loader
1. Is this data required for the initial paint, meta tags, or SEO (e.g. product title, page title)?
- Pass →
awaitit in the loader, return viajson(). Stop. - Fail → Step 2.
2. Is the call genuinely slow (>~50ms, cross-region DB, external API — not in-memory cache)?
- Pass → Pass the unresolved promise through
defer(), wrap in<Suspense>+<Await errorElement={...}>. Stop. - Fail →
awaitit. Deferring fast data adds streaming overhead and flashes a skeleton for no gain.
.server.ts vs runtime typeof window check
1. *Does the module import `node:, prisma, bcrypt, jsonwebtoken, fs, path, or read process.env` at the top level**?
- Pass → Name the file
*.server.ts(or place underapp/.server/— directory form requires the Remix Vite plugin; Classic Compiler supports only the filename suffix). Build fails loud if leaked to client. Stop. - Fail → Step 2.
2. Is the module called only inside `loader`/`action`/`headers` with no top-level side effects?
- Pass →
.server.tsis still preferred for clarity; tree-shaking may work but is unreliable. Stop. - Fail → Step 3.
3. Is the code legitimately isomorphic but needs to branch on environment (logger, feature flag)?
- Pass →
typeof window === "undefined"is acceptable inside a function body — never at module top level (the dead branch can pull server deps into the client graph).
<Link prefetch> mode selection
1. Is the link sensitive, expensive, or has loader side effects (logout, analytics-instrumented page view, mutation-triggering loader)?
- Pass →
prefetch="none". Stop. - Fail → Step 2.
2. Is this an above-the-fold critical nav link likely to be the next click?
- Pass →
prefetch="render". Loader/JS/CSS prefetched immediately. Stop. - Fail → Step 3.
3. Is the link in a long list (table row, search results, feed)?
- Pass →
prefetch="viewport"(fires when scrolled in) or"intent"(fires on hover). Never"render"on long lists. Stop. - Fail → Step 4.
4. Default: prefetch="intent" for standard nav (header, sidebar, footer).
Additional Documentation
- Headers and caching: see references/headers-caching.md for
HeadersFunctionsignature,loaderHeaders/parentHeaders/actionHeaders/errorHeaders, SWR patterns, and parent/child merge semantics. - Streaming: see references/streaming.md for
defer(),<Await>,<Suspense>,abortDelay, error handling, CSP interactions. - Server/client split: see references/server-client-split.md for
.server.*/.client.*(directory form requires the Remix Vite plugin; Classic Compiler supports only the filename suffix), env var handling, thewindow.ENVpattern. - Hydration: see references/hydration.md for
useHydrated,<ClientOnly>,useId, mismatch grep list. - Prefetch: see references/prefetch.md for
<Link prefetch>modes,<PrefetchPageLinks>, thePurpose: prefetchheader trick. - Preload links: see references/links-preload.md for
linksexport, font/CSS preload, image guidance.
Comparison: When to use which API
| Need | API | Module |
|---|---|---|
| Stream slow secondary data | defer() + <Await> | @remix-run/node + @remix-run/react |
| CDN-cache document response | headers export | route module |
| CDN-cache data response | Cache-Control on json()/Response | loader return |
| Server-only module | *.server.ts filename | file convention |
| Browser-only module | *.client.ts filename | file convention |
| Public env vars in client | window.ENV via root loader | pattern |
| SSR-safe IDs | useId() | react |
| Suppress SSR for one component | <ClientOnly> | remix-utils/client-only |
| Branch after hydration | useHydrated() | remix-utils/use-hydrated |
| Prefetch on hover | <Link prefetch="intent"> | @remix-run/react |
| Prefetch on render (above-fold) | <Link prefetch="render"> | @remix-run/react |
| Programmatic prefetch | <PrefetchPageLinks page="/absolute/path"> | @remix-run/react |
| Preload font/CSS | links export | route module |
HTTP Caching with the headers Route Export
Remix v2 has no built-in framework cache. All caching is plain HTTP, which means the headers route export and the loader's response headers are the only knobs — and they control two different responses.
Two cache scopes per route
Every Remix route serves two response types:
1. Document response — full server-rendered HTML, returned on hard navigations and initial loads. Cache-Control here comes from the route's headers export. 2. Data response — JSON returned for ?_data= requests on client-side navigations. Cache-Control here comes from the headers on the Response/json() the loader returns.
They can and usually should differ. A long-form blog post might cache the document for 1 hour at the CDN but cache the data for 5 minutes — so client-side navigations re-fetch sooner while reused HTML stays cheap.
HeadersFunction signature
import type { HeadersFunction } from "@remix-run/node";
export const headers: HeadersFunction = ({
loaderHeaders, // Headers from the loader's Response
parentHeaders, // Headers the parent route would have sent
actionHeaders, // Headers from the action's Response (if this was a POST)
errorHeaders, // Headers from the boundary, when an error renders
}) => {
return {
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
};
};All four *Headers properties are Headers instances — use .get(key), .has(key), .entries(). The return value can be a Headers, a HeadersInit, or a plain object.
Stale-While-Revalidate (SWR) pattern
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
return json(post, {
headers: {
"Cache-Control":
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});Directive cheat sheet:
| Directive | Audience | Effect |
|---|---|---|
max-age=N | browser + CDN | Fresh for N seconds. CDN obeys unless s-maxage overrides. |
s-maxage=N | CDN only | Fresh for N seconds at the shared cache; overrides max-age there. |
stale-while-revalidate=N | CDN | After freshness expires, serve stale for up to N more seconds while refreshing in background. |
public | both | Cacheable by shared caches. Refused by most CDNs if `Set-Cookie` is present. |
private | browser only | Per-user; CDN must not cache. |
no-store | both | Never cache. Use for personalized data. |
Parent/child merge — the notorious gotcha
Default behavior: only the deepest matched route's `headers` runs. If a leaf route has no headers export, Remix walks up to the nearest ancestor that does. This is the biggest source of cache misconfiguration in Remix v2 apps.
Concrete failure mode:
// app/routes/_layout.tsx — parent layout
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=300, s-maxage=3600",
});
// app/routes/_layout.dashboard.tsx — child renders per-user data
// NO headers export!
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
return json({ user, balance: await getBalance(user.id) });
}Result: the dashboard HTML — which contains user-specific data — gets cached at the CDN for an hour with the parent's public, s-maxage=3600 headers, and every visitor sees the first cache fill's data. Severe data-leak bug.
Two defenses
1. Leaf-only headers (recommended): Never export headers from layout/parent routes. Only leaves declare caching policy. Every personalized route then defaults to no-store if you forget — failing closed, not open.
2. Defensive merging in the child: If a parent must set headers, every child must explicitly merge — picking the most conservative (smallest max-age, private over public):
import type { HeadersFunction } from "@remix-run/node";
import { parseCacheControl } from "~/utils/cache";
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
const loader = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parent = parseCacheControl(parentHeaders.get("Cache-Control"));
// Never widen a parent's caching policy from a child
const maxAge = Math.min(loader["max-age"] ?? 0, parent["max-age"] ?? 0);
return {
"Cache-Control": `private, max-age=${maxAge}`,
};
};When headers runs
| Trigger | Source headers passed |
|---|---|
| GET on the leaf route | loaderHeaders, parentHeaders |
| POST/PUT/PATCH/DELETE | actionHeaders, loaderHeaders (post-action loader run), parentHeaders |
| Error boundary rendered | errorHeaders, parentHeaders |
| Resource route | The route's headers export does NOT run — set headers directly on the loader's Response. |
Combining with Set-Cookie
CDNs refuse to cache responses with Set-Cookie when Cache-Control is public. Two safe shapes:
// Shape A: cookie-setting loader returns private/no-store
return json(data, {
headers: {
"Set-Cookie": await commitSession(session),
"Cache-Control": "private, no-store",
},
});
// Shape B: move cookie-setting into the action, keep loaders cookie-free
// so loaders can return public caching headers safely.Vary header
When response varies by Accept-Language, Cookie, or Accept, declare it explicitly so the CDN keys its cache entries correctly:
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
Vary: "Accept-Language",
});Be conservative with Vary — high cardinality (e.g. Vary: User-Agent) destroys hit rate.
Debugging
- Production CDN logs are the source of truth. Don't trust dev — most local servers don't apply
Cache-Control. - Check both response types:
curl -I https://site.com/pagefor the document,curl -I 'https://site.com/page?_data=routes/page'for the data. Cache-StatusandAgeresponse headers from your CDN tell you HIT/MISS and how long the entry has been cached.
When to return no-store
- Authentication callback routes
- Routes that show user-specific data (account, dashboard, cart)
- Routes that set/read sensitive cookies
- API/resource routes returning per-request computed values
- Form action POST responses (Remix usually redirects these anyway)
When unsure, return no-store and measure. Caching personalized data is a much worse bug than missing a perf win.
Per-route examples
Marketing page — long cache, SWR
export async function loader() {
return json(await cms.getPage("home"), {
headers: {
"Cache-Control":
"public, max-age=300, s-maxage=86400, stale-while-revalidate=604800",
},
});
}
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});The CDN holds the page for a day, serving stale for a week while refreshing in the background. The browser caches for 5 minutes.
Blog post — moderate cache, SWR
export async function loader({ params }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
return json(post, {
headers: {
"Cache-Control":
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
},
});
}Authenticated dashboard — no cache
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
return json(
{ user, balance: await getBalance(user.id) },
{
headers: { "Cache-Control": "private, no-store" },
}
);
}
export const headers: HeadersFunction = () => ({
"Cache-Control": "private, no-store",
});API/resource route — JSON with short cache
// app/routes/api.products.tsx — resource route, no default component
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const products = await db.getProducts({
category: url.searchParams.get("category"),
});
return json(products, {
headers: { "Cache-Control": "public, max-age=60, s-maxage=300" },
});
}Resource routes do not run a headers export; set headers on the returned Response/json() only.
ETags and conditional requests
For revalidation efficiency, return an ETag header. Modern clients send If-None-Match on revalidation; if the loader can cheaply check whether the ETag still matches, return 204 to skip the body:
import { json } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
const etag = `"${post.id}-${post.updatedAt}"`;
if (request.headers.get("If-None-Match") === etag) {
return new Response(null, { status: 304 });
}
return json(post, {
headers: {
"Cache-Control": "public, max-age=60, s-maxage=3600",
ETag: etag,
},
});
}ETags are most useful when origin compute is cheap but bandwidth is not.
Hydration Safety
A hydration mismatch happens when the HTML React renders on the server differs from what it produces on the client during the first render. React 18 logs a warning and re-renders the affected subtree on the client, destroying any SSR perf benefit and sometimes producing visible flicker.
Why mismatches happen
Server and client are different environments. The same render function can produce different output when it depends on:
- Time:
new Date(),Date.now(), anything that reads "now." - Randomness:
Math.random(),crypto.randomUUID(). - Locale:
Intl.DateTimeFormat(),Intl.NumberFormat(),toLocaleDateString()— server defaults to system locale (oftenen-US), browser uses visitor's. - Environment:
window.,document.,localStorage,navigator.,process.env.in component bodies. - Conditional branches:
typeof window === "undefined" ? A : Bproduces different JSX on each side. - External mutation: third-party scripts that mutate the DOM before React hydrates (chat widgets, A/B test scripts), browser extensions injecting nodes into
<body>(Grammarly, password managers).
The grep list
Before shipping, search the codebase for these patterns in components or hooks that aren't already inside useEffect, <ClientOnly>, or useHydrated() gates:
new Date(
Math.random(
crypto.randomUUID(
Date.now(
window.
document.
localStorage
sessionStorage
navigator.
Intl.DateTimeFormat()
Intl.NumberFormat()
.toLocaleDateString
.toLocaleTimeString
.toLocaleString
process.env.
typeof windowEach one is a potential mismatch.
useHydrated() — two-pass conditional UI
useHydrated() returns false during SSR and during the first client render, then flips to true on the next render. That two-pass behavior is what keeps server HTML and the initial client HTML identical — only the second render diverges, which React handles correctly.
import { useHydrated } from "remix-utils/use-hydrated";
export function TimezoneBadge({ iso }: { iso: string }) {
const isHydrated = useHydrated();
if (!isHydrated) return <time dateTime={iso}>{iso}</time>;
return (
<time dateTime={iso}>
{new Intl.DateTimeFormat().format(new Date(iso))}
</time>
);
}remix-utils/use-hydrated exports the standard implementation. The hook can also be hand-rolled in any project:
import { useEffect, useState } from "react";
export function useHydrated() {
const [hydrated, setHydrated] = useState(false);
useEffect(() => setHydrated(true), []);
return hydrated;
}Use useHydrated() for conditional UI inside an already-rendered component — the component itself SSRs, but a slice of it switches to a richer client-only representation after hydration.
<ClientOnly> — suppress SSR entirely
When a component cannot SSR at all (Leaflet maps, Mapbox, charts that read window, Three.js canvases), wrap it in <ClientOnly>:
import { ClientOnly } from "remix-utils/client-only";
export function MapPanel() {
return (
<ClientOnly fallback={<MapPlaceholder aspect="16/9" />}>
{() => <LeafletMap />}
</ClientOnly>
);
}The fallback renders during SSR and during the first client render; the child renders after hydration. The child is a function (() => <LeafletMap />), not a JSX expression — this defers evaluation so that imports inside LeafletMap (which may touch window) don't run on the server.
Use <ClientOnly> for entire subtrees that can't SSR. Don't reach for it as a hammer for every browser API — most cases are better handled with useEffect and useHydrated.
useId() — SSR-safe IDs
React's useId() generates an ID that is stable across server and client. Use it for label-for, ARIA references, and anywhere else you'd hand-roll an ID:
import { useId } from "react";
export function LabeledInput({ label }: { label: string }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}Never hand-roll IDs with Math.random(), crypto.randomUUID(), or Date.now() in render. Server and client produce different values; every label-for, ARIA reference, and CSS hook breaks.
Common mismatches and their fixes
new Date() / Date.now()
// BROKEN — server and client compute different "now"
<p>Updated {new Date().toLocaleString()}</p>
// FIX A — render the ISO from the loader, format after hydration
const { updatedAtIso } = useLoaderData<typeof loader>();
const isHydrated = useHydrated();
return (
<p>
Updated{" "}
{isHydrated
? new Date(updatedAtIso).toLocaleString()
: <time dateTime={updatedAtIso}>{updatedAtIso}</time>}
</p>
);Intl.DateTimeFormat() without locale
// BROKEN — server default locale != visitor's locale
new Intl.DateTimeFormat().format(date);
// FIX — pass locale explicitly (use a server-known value, or render
// ISO server-side and reformat in useEffect/useHydrated)
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(date);Math.random()
// BROKEN
const id = `widget-${Math.random()}`;
// FIX — useId() for IDs, or generate the value in the loader and pass via props
const id = useId();localStorage in a useState initializer
// BROKEN — localStorage is undefined on the server
const [theme, setTheme] = useState(localStorage.getItem("theme"));
// FIX — initialize to a server-safe default, sync in useEffect
const [theme, setTheme] = useState<"light" | "dark">("light");
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);Third-party scripts mutating the DOM
Chat widgets, A/B test loaders, Sentry, analytics — if they inject DOM before React hydrates, hydration sees unexpected nodes. Defenses:
- Load them after hydration (in
useEffect). - Inject the script with
asyncordeferand a known-stable mounting point that React doesn't manage. - Set
suppressHydrationWarningon container nodes whose content the script controls.
Browser extensions injecting nodes
Grammarly, password managers, ad blockers can add attributes (data-gramm, cz-shortcut-listen) or wrap inputs. There's no clean fix — the standard workaround is suppressHydrationWarning on the affected element. Use sparingly; it silences real bugs too.
Debugging hydration errors
React's hydration error message includes both the server HTML and the client-rendered output. Diff them character by character — the first divergence is your bug. Then grep the component tree for the patterns in the grep list above, starting at the deepest node.
If the mismatch is inside a third-party component, file an issue upstream and wrap the component in <ClientOnly> as a workaround.
When suppressHydrationWarning is justified
- Browser extension DOM mutation that you can't prevent.
- Intentional time-since-now displays where the server value is acceptable for the first render.
- Cases where the difference is invisible (whitespace differences from a third-party script).
It only silences the warning for the single element it's attached to — children still report mismatches. Don't slap it at the root to suppress all warnings.
links Export — Preloading and Image Notes
The links export injects <link> tags into the document <head>. It's the canonical way to preload critical assets (fonts, CSS), prefetch next-page assets, and set page-level resource hints (dns-prefetch, preconnect).
LinksFunction shape
import type { LinksFunction } from "@remix-run/node";
import interVar from "~/fonts/InterVariable.woff2";
import appStyles from "~/styles/app.css?url";
export const links: LinksFunction = () => [
// Preload a variable font — must be `crossOrigin: "anonymous"` for woff2 in most setups
{
rel: "preload",
as: "font",
type: "font/woff2",
href: interVar,
crossOrigin: "anonymous",
},
// Stylesheet
{ rel: "stylesheet", href: appStyles },
// Favicon
{ rel: "icon", href: "/favicon.png", type: "image/png" },
// Resource hint for an external API
{ rel: "preconnect", href: "https://api.example.com" },
// Page link descriptor — Remix expands into the right prefetch tags
{ page: "/dashboard" },
];Each entry is either an HtmlLinkDescriptor (standard <link> tag attributes) or a PageLinkDescriptor ({ page: "/path" }).
Asset preload patterns
Preload critical font
{
rel: "preload",
as: "font",
type: "font/woff2",
href: interVar,
crossOrigin: "anonymous",
}Preloading fonts that are used immediately in the initial render eliminates the font-swap flash. Only preload fonts that are actually used above the fold — preloading an unused weight is wasted bandwidth.
Stylesheet
{ rel: "stylesheet", href: appStyles }Use ?url import (import appStyles from "~/styles/app.css?url") to get a hashed URL pointing at the built CSS file.
Resource hints
// Just resolves DNS — cheap, useful when you'll connect later
{ rel: "dns-prefetch", href: "https://api.example.com" }
// Resolves DNS + opens TCP + TLS — more expensive, do this for hostnames
// you know you'll connect to during initial render
{ rel: "preconnect", href: "https://api.example.com" }Don't preconnect to more than 3-4 hosts; it costs sockets.
Page link descriptor
{ page: "/dashboard" }Remix expands this into the right combination of <link rel="prefetch"> and <link rel="modulepreload"> tags for the route's data, JS, and CSS. Equivalent to a <PrefetchPageLinks page="/dashboard" /> but at the route's link level — fires on render of the route that exports it.
Nested route inheritance
The links exports from every matched route in the route tree are concatenated into the document head. So a layout's links provides app-wide assets; a child route's links adds page-specific assets without losing the parent's.
// app/root.tsx
export const links: LinksFunction = () => [
{ rel: "preload", as: "font", type: "font/woff2", href: interVar, crossOrigin: "anonymous" },
{ rel: "stylesheet", href: appStyles },
];
// app/routes/blog.tsx
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: blogStyles },
];Both stylesheets render in the head on a /blog/* route.
Image handling — there is no built-in optimizer
Remix v2 has no built-in image optimizer, unlike Next.js. You're responsible for serving correctly sized images. Options:
- Process at build time with
sharp,unpic, orremix-image. - Use a third-party CDN with image transforms (Cloudinary, imgix, Cloudflare Images).
- Pre-render multiple sizes and use
<picture>/srcset.
Two rules that apply regardless of how you serve images:
1. Always set `width` and `height`. Reserves layout space, eliminates Cumulative Layout Shift (CLS).
<img src="/hero.jpg" alt="..." width={1200} height={800} />2. Use `loading="lazy"` for below-the-fold images. Native browser lazy-loading; no JS required.
<img src="/below-fold.jpg" alt="..." width={800} height={600} loading="lazy" />Don't loading="lazy" above-the-fold images — they're needed for LCP, lazy-loading delays them.
Common mistakes
- Preloading a font that's not used above the fold — wasted bandwidth, the file downloads but the user never sees it before the page is interactive.
- Forgetting `crossOrigin: "anonymous"` on font preload — browser issues the preload, then a second request for the actual font use because the credentials mode doesn't match.
- Preloading every CSS file — bundle the critical CSS into a single file, preload that one.
- Image tag with no `width`/`height` — layout shift, poor CLS score.
- `loading="lazy"` on the hero image — slows LCP.
- Preconnecting to 10+ hosts — opens too many sockets, may starve the connections you actually need.
Resource hint cheat sheet
| Hint | Cost | When to use |
|---|---|---|
dns-prefetch | Resolves DNS | Hostnames you might use later (analytics, fallback API) |
preconnect | DNS + TCP + TLS handshake | Hostnames you definitely use during initial render |
prefetch | Full resource download, low priority | Resources for the next page |
preload | Full resource download, high priority | Resources used in current render but discovered late by the browser (fonts referenced in CSS, hero image referenced in inline style) |
modulepreload | Module + dependency graph fetch | JS modules used in current render |
Prefetching with <Link> and <PrefetchPageLinks>
Remix's prefetch turns <Link> into a portal that fetches the target route's data, JS modules, and CSS before the user clicks — so the navigation feels instant. The mechanism is browser-native: Remix inserts <link rel="prefetch"> tags as siblings of the anchor.
<Link prefetch> modes
import { Link } from "@remix-run/react";
<Link to="/dashboard" prefetch="none"> // default — never prefetch
<Link to="/dashboard" prefetch="intent"> // fires on hover/focus
<Link to="/dashboard" prefetch="render"> // fires when the link renders
<Link to="/dashboard" prefetch="viewport"> // fires when scrolled into view| Mode | Trigger | Use for |
|---|---|---|
"none" | Never | Sensitive links (logout), mutation-triggering loaders, analytics-instrumented routes |
"intent" | Hover or focus | Default for nav menus, content cards, anywhere user hover signals intent |
"render" | Immediately, when the link enters the React tree | Above-the-fold critical "next click" (e.g. paginated next-page button) |
"viewport" | When the link scrolls into view (IntersectionObserver) | Long lists, infinite scrolls — prefetch only what's seen |
When prefetch is harmful
- Loaders with side effects — page-view analytics, view counters, mutation-triggering reads. Hover-prefetch inflates the count. Either move side effects out of the loader (do them in an
actionor a separate beacon endpoint), or setprefetch="none"on those links. - `prefetch="render"` on a 200-row table — each row fires a prefetch immediately. Network thrash, wasted bandwidth, possible upstream rate-limit hits. Use
"intent"or"viewport"for long lists. - Logout / destructive routes — even if the loader is GET-safe, hover-prefetch is wasted work and may show up in logs as suspicious traffic. Use
prefetch="none". - Routes behind paywalls — prefetching content the user isn't entitled to load wastes server CPU; gate at the loader anyway, but skip prefetch.
The double-data-request gotcha
When the user hovers a link with prefetch="intent", the browser issues a prefetch request for the route's data. If the loader response has no Cache-Control header, the browser does not cache the response. When the user then clicks, the browser fires the same request again — defeating the prefetch entirely.
The fix: detect the Purpose: prefetch header in the loader and return a short Cache-Control:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const data = await getData(request);
// Browsers and Remix use several header names for this — check all
const purpose =
request.headers.get("Purpose") ||
request.headers.get("X-Purpose") ||
request.headers.get("Sec-Purpose") ||
request.headers.get("Sec-Fetch-Purpose") ||
request.headers.get("X-Moz");
const headers = new Headers();
if (purpose === "prefetch") {
// Cache just long enough that the click finds a hit
headers.set("Cache-Control", "private, max-age=10");
}
return json(data, { headers });
}Without this header, your CDN logs will show 2x the request count on every prefetched route — easy to miss in dev but obvious in production.
CSS selector gotcha: :last-of-type, not :last-child
Remix injects the <link rel="prefetch"> tags as siblings of the <a> element. Briefly, those prefetch tags become the last child of the parent — so any CSS rule like nav a:last-child momentarily misses its target during prefetch.
/* BROKEN — prefetch tags break this */
nav a:last-child { margin-right: 0; }
/* CORRECT */
nav a:last-of-type { margin-right: 0; }<PrefetchPageLinks> — programmatic prefetch
When you want to prefetch a route without an associated <Link> (e.g. the most likely next page based on user behavior), use <PrefetchPageLinks>:
import { PrefetchPageLinks, useLoaderData } from "@remix-run/react";
export default function ArticleList() {
const { articles } = useLoaderData<typeof loader>();
return (
<>
{articles.map((a) => <ArticleCard key={a.id} article={a} />)}
{/* Preload the most likely next page */}
<PrefetchPageLinks page={`/articles/${articles[0]?.slug}`} />
</>
);
}Prefetches data, JS modules, and CSS for the target route.
Gotcha: the page prop must be an absolute path (starts with /). Relative paths silently fail — no warning, no prefetch.
<PrefetchPageLinks page="articles/foo" /> // BROKEN — silently does nothing
<PrefetchPageLinks page="/articles/foo" /> // worksDecision matrix
| Scenario | Choice |
|---|---|
| Header nav link to a static page | prefetch="intent" |
Header nav link to a logged-out-only page (e.g. /login when already logged in) | prefetch="none" |
| Logout button | prefetch="none" |
| "Next page" pagination button above the fold | prefetch="render" |
| Row in a 100+ row table | prefetch="viewport" |
| Search result card | prefetch="intent" |
| Predictive prefetch (programmatic, no link) | <PrefetchPageLinks page="/abs" /> |
| Loader has side effects (logs a view, increments a counter) | prefetch="none", or fix the loader |
Verifying prefetch works
In DevTools Network panel, filter by Initiator → "Other" or look for the Purpose: prefetch request header. On hover (with prefetch="intent") you should see the data request fire once with Purpose: prefetch, then on click no additional request (cache hit). If you see two requests, your loader is missing the Purpose: prefetch cache header.
What prefetch actually downloads
When prefetch fires for a route, Remix inserts three categories of <link> tags:
1. <link rel="prefetch" as="fetch" href="/path?_data=routes/path"> — the loader's JSON data. 2. <link rel="modulepreload" href="/build/routes/path-HASH.js"> — the route's JS module. 3. <link rel="prefetch" href="/build/routes/path-HASH.css"> — the route's CSS, if any.
All three need cache headers (or browser caching defaults) to be effective. The data request is the most common to misconfigure because the loader controls its Cache-Control and most apps default to none.
Interaction with headers export
Prefetch fires GET requests with the same headers as a normal navigation, including the Purpose: prefetch marker (and friends). Your headers export receives them like any other request; you can branch on Purpose inside the loader (as shown above), but the headers export itself doesn't usually need to differ — the loader's response headers are the lever.
If you cache the data response with a longer max-age on prefetch-marked requests than on regular requests, click navigations after a prefetch will reuse the cached entry — exactly what you want. Keep the value small (5-30s) so stale data doesn't outlive its usefulness.
Mobile and slow networks
On mobile or slow connections, prefetch competes with the resources needed for the current page. Aggressive prefetch="render" on many links can starve the critical render path. Two mitigations:
- Use
prefetch="intent"(only fires on hover/focus, which mobile users rarely trigger casually) for less-likely destinations. - Use
prefetch="viewport"for long lists so prefetch only fires for visible rows.
The browser also respects the Save-Data request header on metered connections — Remix-injected prefetch tags are still issued, but the browser may skip them. You can also gate prefetch in your own code based on navigator.connection.effectiveType if you need finer control.
Server/Client Split — .server.ts, .client.ts, and Env Vars
Remix v2 enforces a hard split between server-only and browser-only code through file naming conventions. This is not optional ergonomics — it's the only reliable way to keep server libraries out of the client bundle.
Why tree-shaking is not enough
Remix's compiler strips loader, action, and headers exports from client bundles and the dependencies used inside them — but only if those dependencies have no module side effects. Real-world code routinely violates that:
// app/lib/db.ts — looks fine, but tree-shaking is broken here
import { PrismaClient } from "@prisma/client";
// Top-level side effect — even if no client code calls `db`,
// this `new` expression cannot be tree-shaken out.
export const db = new PrismaClient();Common side-effect patterns that defeat tree-shaking:
- Top-level
newexpressions (new PrismaClient(),new OpenAI()). - Top-level function calls (
initializeApp(config),console.log("...")). - Module-evaluation imports with effects (Sentry init, OpenTelemetry instrumentation).
- Higher-order function wrappers around loaders:
export const loader = withAuth(async (args) => {...})—withAuthruns at module evaluation time and pins server-only deps into the client graph.
Rule: any module that imports node:* builtins (fs, path, crypto), database clients (prisma, pg, mysql2), crypto/auth libs (bcrypt, jsonwebtoken, argon2), or reads process.env at the top level must be named *.server.ts or live under app/.server/.
.server.* file convention
Two equivalent forms:
app/lib/db.server.ts # filename suffix — works in both compilers
app/.server/db.ts # directory form — Vite-onlyBuild behavior:
- Vite plugin (
@remix-run/devwith Vite): if a.servermodule is reachable from the client graph, the build fails loud with an error pointing at the offending import chain. - Classic Compiler (legacy
esbuild-based):.serverviolations are silent — the bad import is replaced with an empty module at runtime, producing confusingX is not a functionerrors. The Classic Compiler also supports only the filename suffix, not the directory form.
Prefer Vite for new projects. If stuck on the Classic Compiler, be extra vigilant about .server naming.
// app/lib/db.server.ts — never bundled into the client
import { PrismaClient } from "@prisma/client";
import { env } from "./env.server";
export const db = new PrismaClient({
datasources: { db: { url: env.DATABASE_URL } },
});
// app/routes/users.tsx — safe to import a .server module
import { json } from "@remix-run/node";
import { db } from "~/lib/db.server";
export async function loader() {
return json({ users: await db.user.findMany() });
}.client.* file convention
The mirror of .server: modules that should never be bundled or evaluated on the server.
app/lib/analytics.client.ts
app/.client/leaflet-init.tsOn the server, exports from a .client module are undefined. This matters: a route component that imports a .client value at the top level and uses it in render will crash during SSR. Use .client modules from inside useEffect, event handlers, or <ClientOnly> children — never in render or at module top level of a server-rendered component.
// app/lib/analytics.client.ts
import posthog from "posthog-js";
export function track(event: string, props?: Record<string, unknown>) {
posthog.capture(event, props);
}
// In a route component:
import { useEffect } from "react";
import { track } from "~/lib/analytics.client";
export default function Page() {
useEffect(() => {
track("page_viewed"); // safe — useEffect runs only on the client
}, []);
return <h1>Hello</h1>;
}Env vars: server vs window.ENV
Server-only env vars are read via process.env.X only inside `loader`, `action`, or a `.server` module:
// app/lib/env.server.ts
export const env = {
DATABASE_URL: must("DATABASE_URL"),
STRIPE_SECRET_KEY: must("STRIPE_SECRET_KEY"),
};
function must(key: string): string {
const v = process.env[key];
if (!v) throw new Error(`Missing env: ${key}`);
return v;
}Never reference process.env in a component body or in a non-.server utility imported by a component. process.env doesn't exist in the browser, and the bundler may inline the value, leaking the secret.
Public env vars via window.ENV
Public values (Stripe publishable key, PostHog key, public API URLs) reach the client through the root loader, injected into window.ENV via a <script> tag:
// app/root.tsx
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
export async function loader(_args: LoaderFunctionArgs) {
return json({
ENV: {
STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
POSTHOG_KEY: process.env.POSTHOG_KEY,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
},
});
}
export default function App() {
const data = useLoaderData<typeof loader>();
return (
<html lang="en">
<body>
<Outlet />
<ScrollRestoration />
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(data.ENV)}`,
}}
/>
<Scripts />
</body>
</html>
);
}
// app/types/globals.d.ts
declare global {
interface Window {
ENV: {
STRIPE_PUBLIC_KEY: string;
POSTHOG_KEY: string;
PUBLIC_API_URL: string;
};
}
}Critical: only put public values into ENV. Anything in window.ENV is exposed to every visitor. Never return json({ ENV: process.env }) — that ships every secret.
JSON.stringify is not safe-for-inline-script
JSON.stringify does not escape </script> or U+2028/U+2029 line separators. If any string in your ENV payload contains </script>, it breaks out of the script context — an XSS vector. For hardened apps use serialize-javascript:
import serialize from "serialize-javascript";
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${serialize(data.ENV, { isJSON: true })}`,
}}
/>Or hand-escape <, >, &, ', U+2028, U+2029 before injection.
Anti-patterns
- `import { prisma } from "~/lib/db"` with no
.serversuffix. Fix: rename todb.server.ts. - `process.env.STRIPE_SECRET_KEY` in a component. Fix: read in loader, pass via
useLoaderData()only if it's the publishable key — never the secret. - `export const loader = withAuth(async (args) => {...})` — wrapper runs at module evaluation. Fix: call
await requireAuth(args)inside the loader body. - `useState(localStorage.getItem("theme"))` in a server-rendered component. Fix: initialize to a server-safe default, then sync from
localStorageinuseEffect; or wrap in<ClientOnly>. - Importing `node:fs`, `path`, `bcrypt`, `jsonwebtoken` from a non-`.server` utility consumed by a route component. Fix: rename the file to
*.server.ts. - `typeof window === "undefined"` at module top level to branch imports — the dead branch can still pull server deps into the client graph. Fix: split into
.server.tsand.client.tsfiles.
Diagnosing leaks
If a build succeeds but the client bundle crashes with Module not found: 'fs' or prisma is undefined:
1. Grep for the offending import in any non-.server file. 2. Use Remix's build output to inspect what's in the client bundle (build/client/assets/). 3. Move the import into a .server.ts module and re-build. Vite will surface any remaining client-graph references.
The whole point of the .server convention is to convert silent runtime leaks into loud build failures. Use it liberally.
Streaming with defer and <Await>
Streaming lets a Remix route return its initial HTML quickly while slow promises resolve afterward, with the browser progressively rendering as each chunk arrives. Useful for routes where critical data (page title, hero content) is fast but secondary data (recommendations, related items, reviews) is slow.
When streaming actually helps
Streaming improves TTFB (time to first byte) and LCP (largest contentful paint) only when:
1. The critical data is meaningfully faster than the secondary data (≥100ms gap). 2. The hosting platform supports streamed responses (Node, Cloudflare Workers with streaming, Vercel Edge with streaming — not all edge runtimes; many buffer the full response). 3. The slow data takes >50ms — otherwise the suspense skeleton flashes and net perceived performance regresses.
If those don't hold, await everything and return a json() response.
Canonical defer shape
import { defer, type LoaderFunctionArgs } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
// Critical: await — needed for the initial paint, meta tags, SEO
const product = await db.getProduct(params.id);
// Secondary: kick off BEFORE any other await, pass the unresolved promise
const reviewsPromise = db.getReviews(params.id); // NO await
return defer({ product, reviews: reviewsPromise });
}
export default function Product() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
</>
);
}Invariant: every promise passed to defer() must be created before any subsequent await in the loader. Otherwise the loader still blocks on the slow call before returning, and streaming gains nothing.
// BROKEN — reviewsPromise starts AFTER awaiting db.getOrders, so the loader
// doesn't return until orders finishes. Streaming achieves nothing.
const product = await db.getProduct(params.id);
const orders = await db.getOrders(params.id); // blocks
const reviewsPromise = db.getReviews(params.id); // too late
return defer({ product, orders, reviews: reviewsPromise });Error handling in <Await>
Always pass errorElement. Without it, a rejected deferred promise bubbles to the route's ErrorBoundary and tears down the whole route — defeating the streaming benefit (the user already saw partial content, then it vanishes).
import { Await, useAsyncError } from "@remix-run/react";
import { Suspense } from "react";
function ReviewsError() {
const err = useAsyncError() as Error;
return <p role="alert">Reviews unavailable: {err.message}</p>;
}
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>useAsyncValue() reads the resolved value. useAsyncError() reads the rejection.
abortDelay — keep it low
// app/entry.server.tsx
import { RemixServer } from "@remix-run/react";
export default function handleRequest(/* ... */) {
return /* renderToPipeableStream */(
<RemixServer context={remixContext} url={request.url} abortDelay={5000} />
// ...
);
}Default is 5000ms. Don't bump it to 30s "to be safe" — slow upstream calls then hold the connection open, exhausting server worker pools and pushing p99 latency through the roof. If upstreams are slow, set per-call timeouts inside the loader:
const reviewsPromise = Promise.race([
db.getReviews(params.id),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("reviews timeout")), 3000)
),
]);Interaction with headers export
Streaming and Cache-Control interact awkwardly:
- The HTTP response headers go out before any deferred data resolves — so
headersmust commit to a caching policy without knowing whether the slow data succeeds. - If you cache a deferred response and a deferred promise rejected, the cached HTML contains the partial-then-error UI for everyone.
Safer defaults:
// Avoid public caching on deferred responses
export const headers: HeadersFunction = () => ({
"Cache-Control": "private, no-store",
});Or only defer on routes where every deferred promise is idempotent and resilient.
TypeScript: <Await resolve={...}> types
useLoaderData<typeof loader>() returns Promise<T> for deferred fields. Pass that promise directly to <Await resolve={...}> — don't call .then() on it during render:
const { reviews } = useLoaderData<typeof loader>();
// reviews: Promise<Review[]>
// CORRECT
<Await resolve={reviews}>{(r) => <ReviewList reviews={r} />}</Await>
// WRONG — calling .then() in render kicks off another async chain every render
<Await resolve={reviews.then((r) => r.filter(/*...*/))}>If you need to transform, do it in the loader before passing through defer:
const reviewsPromise = db.getReviews(params.id).then((r) => r.filter(visible));
return defer({ reviews: reviewsPromise });Streaming and CSS-in-JS
Emotion, styled-components, and similar libraries with default config in entry.server.tsx require collecting styles during a full server render — incompatible with streaming. The page can't stream until rendering completes. Use Tailwind, Vanilla Extract, CSS Modules, or a CSS-in-JS library with documented streaming support.
CSP and streaming
Streaming appends <script> chunks to the HTML to resolve deferred promises in the browser. These trip strict Content Security Policy:
- Easy escape: allow
'unsafe-inline'forscript-src(degrades CSP value). - Hard but correct: thread a nonce through
<RemixServer>,<Scripts>,<ScrollRestoration>, and<LiveReload>, and setscript-src 'self' 'nonce-...'.
Anti-patterns
- `<Suspense>` wrapping awaited data — Suspense never triggers; dead code that misleads future readers. Remove the boundary, or move the field to
defer. - `defer` for data that comes from an in-memory cache — protocol overhead (extra script tags, extra render pass) makes TTI worse for sub-50ms data.
- Mounting `<Await>` without `<Suspense>` — React throws.
- `await Promise.all([...])` of deferred fields in the loader before `defer` — same as creating the promise after an
await: kills streaming. - High `abortDelay` — exhausts worker pools.
Deferring multiple values
return defer({
product, // awaited
reviews: db.getReviews(params.id), // promise
related: db.getRelated(params.id), // promise
recommendations: db.getRecs(params.id), // promise
});Each is independently rendered through its own <Await>. Order them by likely resolution time so the fastest chunks appear first.