Skip to content

React Server Components on Lambda Web Functions

A React Router RSC app on Lambda Web Functions behind CloudFront: the entry shim, NODE_ENV, streaming, request-scoped state, partial rendering, and the origin hop.

Ayhan Sipahi Ayhan Sipahi

Hosting a React Server Components app on AWS Lambda has so far required an adapter. That code turns an invocation event into a Request and pushes the streamed Response back through a Lambda-specific API. It breaks whenever the framework changes the shape of its server build. On Lambda Web Functions the RSC server is a plain Node process listening on a port, so the adapter problem disappears. Three other things then decide whether the deployed app is correct and fast: NODE_ENV set before the server build loads, the framework’s revalidation rules, and the hop between CloudFront and the origin. The path below takes a React Router 8.5 RSC Framework Mode app end to end: the entry file, the deploy commands, streaming checks, request-scoped state, partial rendering, prerendering, and the CloudFront settings. Each step carries the numbers the trial produced.

The stack was react-router and @react-router/dev 8.5.1, React 19.2, Vite 8.3.5, @vitejs/plugin-rsc 0.5.35, @remix-run/node-fetch-server 0.13.0, Express 5.3.0, compression 1.8.2, the nodejs24.x runtime on arm64, and AWS CLI 2.38.4. RSC behaviour appears only where it changes a hosting decision. All measurements come from one client machine in Europe against one AWS account, so read them as one environment, not as a benchmark. The code, the lockfiles, the CloudFront templates, the getManifestRoute patch script, and the bench scripts are in ayhansipahi/rsc-on-lambda-web-functions (opens in new tab). The earlier post on the Hono lambdalith covers the Web Functions resource model, auth types, and endpoint settings. The next section restates only what an RSC server depends on.

What the Runtime Hands an RSC Server#

The process must listen on the host:port given in AWS_LAMBDA_HTTP_ENDPOINT within 10 seconds. The runtime sets no PORT (SAM WebFunction reference (opens in new tab)), and the entry point is index.js at the package root. One environment has 2 vCPUs and 2 GB of memory and accepts up to 64 concurrent requests by default (maxConcurrencyPerEnvironment, configurable from 1 to 128). That number is a ceiling: Lambda sets the actual per-environment limit dynamically below it. The request timeout defaults to 30 seconds (3 to 900), and the deployment package may reach 512 MB unzipped. The endpoint speaks HTTP/1.1, per the Web Functions HTTP page (opens in new tab), and does not compress. In the trial, TLS ALPN offered h2 and http/1.1 and the endpoint selected http/1.1; a request sent with accept-encoding: gzip, br came back without content-encoding. Billing has three dimensions per the Lambda FAQ (opens in new tab): requests, active CPU time, and memory. CPU time spent waiting on I/O is not billed.

An RSC server fits this shape. A request holds its connection while Suspense boundaries resolve, which is the server-side rendering workload the launch post names as a target. Because one event loop serves up to 64 requests at once, anything stored at module scope is shared between users. That makes module scope the right place for SDK clients, connection pools, and immutable configuration, and the wrong place for data that belongs to one request; the concurrency section below tests the second case. The endpoint speaking only HTTP/1.1 matters later as well. The hop between CloudFront and the origin has no multiplexing, so whether a connection is reused shows up directly in latency.

The Entry File#

The whole deployable server is one file.

// Lambda Web Functions entry: the runtime loads index.js and probes AWS_LAMBDA_HTTP_ENDPOINT.
import zlib from "node:zlib";
import compression from "compression";
import express from "express";
import { createRequestListener } from "@remix-run/node-fetch-server";

// Must be set before React loads: without it the server uses React's dev build, whose
// Flight payload carries debug rows the production client never hydrates. Static imports
// are hoisted, so the build is imported dynamically below.
process.env.NODE_ENV ??= "production";
// RR 8.5 exports `default: { fetch }`, not a bare handler as the RSC docs show.
const { default: build } = await import("./build/server/index.js");

const [host, port] = (process.env.AWS_LAMBDA_HTTP_ENDPOINT || "0.0.0.0:3000").split(":");

const app = express();
app.use("/assets", express.static("build/client/assets", { immutable: true, maxAge: "1y" }));
app.use(
  express.static("build/client", {
    // Prerendered pages: the CDN keeps them 5 min, browsers always revalidate.
    setHeaders: (res, path) => {
      if (/\.(html|rsc)$/.test(path)) res.setHeader("Cache-Control", "public, max-age=0, s-maxage=300");
      // mime-db has no .rsc entry, so send falls back to application/octet-stream, which
      // CloudFront does not compress. text/x-component is on its compressible list.
      if (path.endsWith(".rsc")) res.setHeader("Content-Type", "text/x-component");
    },
  }),
);
// Neither the Web Functions endpoint nor CloudFront compresses streamed (no Content-Length)
// responses. Flushing every write keeps Suspense chunks streaming instead of buffering them.
app.use(
  compression({
    flush: zlib.constants.Z_SYNC_FLUSH,
    brotli: { flush: zlib.constants.BROTLI_OPERATION_FLUSH },
  }),
);
app.use(createRequestListener(build.fetch));
app.listen(port, host, () => console.log(`listening on ${host}:${port}`));

Four decisions in that file matter, in the order they appear.

NODE_ENV before the build loads. ESM static imports are hoisted above every other statement in the module. A static import of the server build would therefore load React before the assignment runs; the dynamic await import() is what enforces the order. Without production, the server picks React’s development build and nothing reports it. In the trial the /dash/a document grew from 5,471 to 17,786 bytes, and the Flight payload carried debug rows with stack positions such as "/server/index.js",6481,17. The production client never called hydrateRoot, and every link fell back to a full document load. The console stayed clean. react-router-serve avoids all of this with process.env.NODE_ENV = process.env.NODE_ENV ?? "production"; at the top of its CLI. The problem therefore appears only once a custom shim replaces it. The runtime did not supply the variable in this trial: until the shim set it, the server loaded React’s development build. ??= only fills a missing value. A revision whose environment variables set NODE_ENV=development keeps the development build, so leave it unset there or assign "production" outright.

build.fetch as the handler. The React Router RSC how-to shows the server build’s default export passed straight to createRequestListener. The 8.5.1 build exports default: { fetch }, and react-router-serve itself detects an RSC build by checking for default.fetch. Passing the object throws TypeError: handler is not a function on the first request.

Static files ahead of compression. The hashed /assets directory ships with immutable and a one-year max-age. The file name carries a content hash, so changed content gets a new URL and browsers do not even revalidate for a year. Prerendered .html and .rsc files get public, max-age=0, s-maxage=300: CloudFront keeps them for five minutes, browsers revalidate on every visit. Both static handlers sit before the compression middleware on purpose. express.static sends Content-Length, so CloudFront compresses those files at the edge under its documented rules. The app only has to compress what it streams. One file type needed a header for that. The MIME database behind express.static has no .rsc entry, so about.rsc went out as application/octet-stream, which is not on CloudFront’s list of compressible types. With Content-Type: text/x-component set in setHeaders, CloudFront served the file with Brotli, 725 bytes on the wire instead of 2,039.

Compression that flushes. compression wraps the RSC handler with Z_SYNC_FLUSH for gzip and BROTLI_OPERATION_FLUSH for Brotli. The middleware passes zlib and Brotli options through. Its README notes that it “does not work out of the box with server-sent events” without flushing, and a chunked RSC response has the same problem. Why the app must compress at all belongs to the CloudFront section. The trade-off is plain. Every flushed chunk compresses slightly worse than a buffered stream would (Node’s zlib docs (opens in new tab) say flushing “may come at the cost of degraded compression quality”), and the function spends CPU on it, which is billed as active CPU time. react-router-serve makes the opposite call and skips its compression middleware entirely for RSC builds. The trial measured the bytes, not the CPU time. For a document that drops from 5,471 to 1,726 bytes the smaller transfer is the likely win, but that is a judgment, not a measurement. Check active CPU in the function’s metrics, and drop the middleware when the function is CPU-bound and payloads are small.

Build, Prune, Deploy#

The deploy command does not bundle, so the build runs first and the development dependencies leave before packaging.

npm run build
npm prune --omit=dev
aws lambda-web deploy --name rr-rsc-webfn --code . --create --auth-type ApplicationManaged --region us-east-1

react-router build writes build/server and build/client. aws lambda-web deploy --code . zips the directory as it stands. The CLI reference states “The command does not bundle the application”, and node_modules ships inside the zip. npm prune --omit=dev removes Vite, @vitejs/plugin-rsc, and TypeScript first. The package came to about 45 MB, well under the 512 MB limit. Hidden paths such as .env and .git/ are skipped, with a documented allow list (.next/, .output/, and a few others). Later deploys use the same command without --create. Before the next build, npm install has to run again. That reinstall also reverts anything patched under node_modules; the partial-rendering section comes back to this.

The version pin and the function name both come out of this step. @vitejs/plugin-rsc is pinned to 0.5.35 because versions 0.5.36 to 0.5.38 failed the React Router 8.5.1 build with Error: Vite RSC assets manifest not found. The cause sits in React Router’s own Vite plugin (remix-run/react-router#15671 (opens in new tab)). The fix is merged for @react-router/dev 8.5.2, so the pin can go once that release ships. Function names are unique within an account across all Regions, per the SAM AWS::Serverless::WebFunction reference (opens in new tab). The Frankfurt copy therefore got its own name, rr-rsc-webfn-fra, with --region eu-central-1.

Verifying That Streaming Survives#

The /stream route has two Suspense boundaries that resolve after 1 and 3 seconds.

import { Suspense } from "react";

// Async server component: resolves after `ms`, so its HTML arrives in a later chunk.
async function Slow({ ms }: { ms: number }) {
  await new Promise((r) => setTimeout(r, ms));
  return <p>slow-{ms}ms rendered at {new Date().toISOString()}</p>;
}

export function ServerComponent() {
  return (
    <main className="space-y-2">
      <h1 className="text-2xl font-semibold">shell rendered at {new Date().toISOString()}</h1>
      <Suspense fallback={<p>loading-1000</p>}>
        <Slow ms={1000} />
      </Suspense>
      <Suspense fallback={<p>loading-3000</p>}>
        <Slow ms={3000} />
      </Suspense>
    </main>
  );
}

The check prints when each response chunk arrives, which is what separates streaming from buffering.

// Prints when each response chunk arrives, so buffering vs streaming is visible.
// usage: node probe.mjs <url> [Accept header]
const [url, accept = "text/html"] = process.argv.slice(2);
const t0 = performance.now();
const res = await fetch(url, { headers: { accept } });
const ms = () => String(Math.round(performance.now() - t0)).padStart(5);
console.log(`${ms()}ms status=${res.status} type=${res.headers.get("content-type")} te=${res.headers.get("transfer-encoding")}`);
const dec = new TextDecoder();
let n = 0;
for await (const chunk of res.body) {
  const text = dec.decode(chunk, { stream: true });
  const hits = text.match(/shell rendered|loading-\d+|slow-\d+ms/g) ?? [];
  console.log(`${ms()}ms chunk#${++n} ${chunk.length}B ${[...new Set(hits)].join(",")}`);
}
console.log(`${ms()}ms done, ${n} chunks`);

Against the us-east-1 endpoint directly, the first bytes arrived at about 124 ms and the two Suspense chunks at about 1.12 s and 3.12 s. Both the HTML document and the .rsc payload for the same route arrived chunked, with no buffering at the endpoint. The assertion that matters is chunk arrival time. A buffered response also finishes at about 3.1 s, so a check on total time passes either way. After the compression middleware went in, the chunks still landed at +1 s and +3 s. That is the test that the flush options do their job, and it is worth re-running after every middleware change.

Request-Scoped State Under 64 Concurrent Requests#

A personalized route reads its cookie in the loader, marks the response private, and sets the cookie through a redirect.

import { redirect } from "react-router";
import { NameField, SubmitButton } from "../field";

const readName = (request: Request) =>
  decodeURIComponent(request.headers.get("cookie")?.match(/(?:^|;\s*)name=([^;]+)/)?.[1] ?? "");

// Personalized page: must never be cached by a shared CDN.
export function headers() {
  return { "Cache-Control": "private, no-store" };
}

export function loader({ request }: { request: Request }) {
  return { name: readName(request) };
}

export async function action({ request }: { request: Request }) {
  const name = String((await request.formData()).get("name") ?? "").slice(0, 32);
  // POST/redirect/GET: in RSC mode (RR 8.5.1) loaderData is null when rendering the action response.
  return redirect("/me", {
    headers: { "Set-Cookie": `name=${encodeURIComponent(name)}; Path=/; HttpOnly; Secure; SameSite=Lax` },
  });
}

export function ServerComponent({ loaderData }: { loaderData: { name: string } }) {
  return (
    <main className="space-y-4">
      <h1 className="text-2xl font-semibold">hello {loaderData.name || "stranger"}</h1>
      <form method="post" className="flex items-end gap-2">
        <NameField defaultValue={loaderData.name} />
        <SubmitButton>Save</SubmitButton>
      </form>
    </main>
  );
}

private, no-store matters once a CDN sits in front, because the cache policy in the CloudFront section keeps cookies out of the cache key. The redirect also sidesteps a rendering path in 8.5.1. Returning data(null, { headers }) from the action rendered the action response with loaderData === null, and the component failed with TypeError: Cannot read properties of null (reading 'name') as an HTTP 500.

The broken variant keeps the cookie value in a module variable and awaits 50 ms as a stand-in for a database call. During those 50 ms another request on the same event loop overwrites current. When the first request wakes up and reads the value, it sees someone else’s name.

// Deliberately broken. Module-level state is shared by every concurrent
// request in one Web Functions environment, so names leak across users.
let current = "";

export async function loader({ request }: { request: Request }) {
  current = decodeURIComponent(request.headers.get("cookie")?.match(/(?:^|;\s*)name=([^;]+)/)?.[1] ?? "");
  await new Promise((r) => setTimeout(r, 50)); // stand-in for a DB call
  return { name: current };
}

export function ServerComponent({ loaderData }: { loaderData: { name: string } }) {
  return <p>hello {loaderData.name}</p>;
}

The test sent 200 parallel requests against the us-east-1 endpoint, each with its own cookie name=u{i}. In most runs this route returned 127 to 128 responses carrying another user’s name; one run showed 9, one showed 0. The request-scoped version returned 0 of 200 in every run. Locally, in a single process, the broken route answered 63 of 64 wrong.

The spread between runs most likely follows how the 200 requests land across environments. The scaling page describes a dynamic per-environment concurrency limit that “starts conservatively for a new Web Function”. The same test can therefore fan out over more or fewer environments from one run to the next. That link is inferred from the documentation. The same page notes that the first load test on a new function might use more execution environments than later runs, and advises using the results of later runs. A leak test run right after a deploy is therefore the one most likely to under-report. The practical consequence holds either way: a run with zero wrong answers does not prove a route is safe. The runtimes page states the rule: “avoid storing per-request state in global variables”. The earlier post’s concurrency audit lists what to look for in a codebase that was written for one request per process.

Rendering Only the Route That Changed#

After the first document, a link click fetches the Flight payload for the next route from /path.rsc and the client patches the tree in place. By default React Router re-renders root, layout, and page on the server for every such request. The opt-out is shouldRevalidate on the routes that do not need it.

// app/routes/dash.tsx
// Skip re-rendering the layout on child-to-child navigation.
// RR 8.5.1 drops this export from route discovery patches (getManifestRoute), so it only
// applied when /dash was the first page loaded; node_modules/react-router is patched for this demo.
export function shouldRevalidate() {
  return false;
}
// app/root.tsx
import type { Route } from "./+types/root";

// Root has no loader and NavLink computes the active state on the client, so a link click
// does not need the server to re-render it. Form submits still revalidate.
export function shouldRevalidate({ formMethod, defaultShouldRevalidate }: Route.ShouldRevalidateFunctionArgs) {
  return formMethod ? defaultShouldRevalidate : false;
}

With both in place the client requests /dash/b.rsc?_routes=../routes/dash.child. The layout’s render timestamp stays the same across child navigations, client state in the layout (a counter) survives, and root never re-renders. The payload was 3,292 bytes entering /dash and 2,283 bytes for each child-only navigation afterwards. The hosting consequence sits in that URL. _routes is part of the request identity, so the CloudFront cache policy has to forward and key on all query strings. If the parameter does not reach the origin, the server renders the whole tree again. If it is missing from the key, different _routes values would share one entry on any response marked cacheable.

The comment in dash.tsx points at a React Router 8.5.1 behaviour. getManifestRoute, the server function behind route-discovery patches (used by lazy discovery and by routeDiscovery: "initial"), omits shouldRevalidate from the route object it returns. A layout’s shouldRevalidate therefore applies only when that layout was part of the first document load. Entering /dash through a link re-rendered the layout on every child navigation. The one-line fix, verified live, adds shouldRevalidate: route.shouldRevalidate to the object getManifestRoute returns, in both react-router/dist/development/index-react-server.js and react-router/dist/production/index-react-server.js. The trial edited node_modules directly, which the next npm install reverts. Persist it with your package manager’s patch mechanism and remove it once an upstream release ships the field. The change inside getManifestRoute:

   links: route.links,
-  meta: route.meta
+  meta: route.meta,
+  shouldRevalidate: route.shouldRevalidate
 };

The setting has a cost on each side. Returning false unconditionally keeps the layout as rendered until a full reload, including after client-side form submissions. A layout that must refresh after a mutation should use the root’s pattern, formMethod ? defaultShouldRevalidate : false; the trial’s dash layout had no mutations, so it returned false outright. A layout that shows live data needs the default. On 8.5.1 it also means carrying a patch to a framework dependency, which every upgrade has to re-check. For a layout that owns expensive data, the patch earns its keep: every child navigation saves a layout render and about 1 KB in this app. For a thin layout, default revalidation costs less than the patch.

Prerendered Routes at the Edge#

Routes that change slower than a cache TTL can leave the function entirely.

import type { Config } from "@react-router/dev/config";

export default {
  prerender: ["/about"],
} satisfies Config;

Prerendering works in RSC Framework Mode on 8.5.1: the build emitted both build/client/about/index.html and build/client/about.rsc. The shim serves both through express.static with public, max-age=0, s-maxage=300. Through CloudFront, an edge hit answered in about 15 ms against about 119 ms from the us-east-1 origin.

Client JavaScript is the other number worth tracking per route. The framework shell came to about 114 KB gzipped. In a first version, a single "use client" barrel file exported every Base UI component, and the root layout imported the navigation from it. Every page therefore loaded the whole module, about 42 KB more on every page (about 114 to 156 KB). One client module per component brought pages that do not use the UI back to about 114 KB. The navigation is one such module.

"use client";

import { NavLink } from "react-router";

const pages = [
  ["/", "Home"],
  ["/stream", "Stream"],
  ["/me", "Cookie"],
  ["/dash/a", "Dash"],
  ["/about", "About"],
] as const;

export function Nav() {
  return (
    <nav className="flex gap-1 border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
      {pages.map(([to, label]) => (
        <NavLink key={to} to={to} end={to === "/"}>
          {label}
        </NavLink>
      ))}
    </nav>
  );
}

CloudFront in Front of the Endpoint#

The distribution used the Web Function endpoint as a plain HTTPS custom origin: an ApplicationManaged endpoint, no origin access control. That is the simplest shape and it was enough for the trial. The endpoint stays reachable at its own domain, though, bypassing CloudFront and any WAF rule attached to it. For a production front door, IamAuth with OAC closes that path; the earlier post describes the setup, and it was not exercised here.

h2 or h3

HTTP/1.1, reused within keepalive

chunked, br compressed

chunks passed through

Browser

CloudFront edge (key: path + query)

Web Function: shim + RSC handler

Cache Policy and the Host Header#

The first distribution used the managed UseOriginCacheControlHeaders-QueryStrings cache policy (ID 4cc15a8a-d715-48a4-82b8-cc0b614638fe) with the managed AllViewerExceptHostHeader origin request policy (ID b689b0a8-53d0-40ab-baf2-68738e2966ac), which the managed origin request policies page (opens in new tab) describes as intended for Lambda Web Function origins. Every path except /assets/* answered 502 ERROR The request could not be satisfied with x-cache: Error from cloudfront. The asset behaviour used CachingOptimized and kept working.

The cause is the cache key. That managed policy includes Host, Origin, X-HTTP-Method-Override, X-HTTP-Method, X-Method-Override, and all cookies in the key. The CloudFront origin-request page states: “All URL query strings, HTTP headers, and cookies that you include in the cache key (using a cache policy) are automatically included in origin requests.” So Host: <distribution>.cloudfront.net reached the origin even though the origin request policy had removed it. The origin settings page adds the consequence: “If you are using an origin request policy that forwards the viewer host header to the origin, the origin must respond with a certificate that matches the viewer host header.” The Web Function’s certificate does not, so TLS to the origin failed and CloudFront answered 502. The earlier post’s pitfall list describes the 404 the endpoint itself returns when it receives a foreign Host. In this trial the request never got that far.

The fix is a custom cache policy with no headers and no cookies in the key: only the path, all query strings, and the normalized Accept-Encoding value that CloudFront adds whenever gzip or Brotli is enabled in the policy.

{
  "Name": "rr-rsc-webfn-origin-cc-qs",
  "Comment": "Honor origin Cache-Control; key on query strings only (no Host: it breaks SNI to the Web Function origin)",
  "MinTTL": 0,
  "DefaultTTL": 0,
  "MaxTTL": 31536000,
  "ParametersInCacheKeyAndForwardedToOrigin": {
    "EnableAcceptEncodingGzip": true,
    "EnableAcceptEncodingBrotli": true,
    "HeadersConfig": { "HeaderBehavior": "none" },
    "CookiesConfig": { "CookieBehavior": "none" },
    "QueryStringsConfig": { "QueryStringBehavior": "all" }
  }
}

Query strings stay in the key for _routes. The origin request policy stays AllViewerExceptHostHeader, so cookies and the viewer’s User-Agent still reach the function without being part of the key. The trade-off: with cookies out of the key, any response the origin marks cacheable is shared across users. Personalized routes must therefore send private, no-store, as /me does. The default TTL of 0 limits the exposure to routes that explicitly send s-maxage or max-age. A response without Cache-Control is not cached at all.

Compressing What the Edge Passes Through#

CloudFront compresses a response only when the origin sends Content-Length and the size falls between 1,000 and 10,000,000 bytes. Without the header, the object passes through as is. A streamed RSC or HTML response has no Content-Length, and the endpoint does not compress either. Without the middleware in the shim, every document and .rsc payload arrived at the viewer uncompressed. With it, /dash/a went from 5,471 to 1,726 bytes with Brotli and its .rsc payload from 2,344 to 755 bytes. The streaming chunks still arrived at +1 s and +3 s. When the origin sends Content-Encoding, CloudFront does not compress again, so Compress: true on the behaviour and the middleware in the app do not fight.

HTTP/2 Only on the Viewer Side#

Viewer to CloudFront ran over h2, with h3 enabled through HttpVersion: http2and3; browser navigations were observed over h3. The custom-origin page states: “CloudFront forwards requests to your custom origin using HTTP/1.1.” The endpoint speaks HTTP/1.1 as well. There is no multiplexing on the origin hop, so each concurrent request from an edge needs its own origin connection. Whether that connection already exists is the keepalive question.

Origin Keepalive#

CloudFront’s default origin keepalive is 5 seconds. The CloudFront quotas page (opens in new tab) lists the per-origin range as 1 to 300 seconds, so 60 needs no quota request. With the origin in us-east-1 and the default, browser navigations through CloudFront took about 330 ms. Direct to the endpoint, from the same client, they took 125 to 132 ms.

A response headers policy makes the cause visible.

{
  "Name": "rr-rsc-webfn-server-timing",
  "Comment": "Expose CloudFront Server-Timing (upstream connect, first byte) on every response",
  "ServerTimingHeadersConfig": { "Enabled": true, "SamplingRate": 100 }
}

The cdn-upstream-connect metric is “the number of milliseconds between when the origin DNS request completed and a TCP (and TLS, if applicable) connection to the origin completed. A value of zero (0) indicates that CloudFront reused an existing connection.” With the 5-second default, 7 of 8 requests sent at 6-second gaps opened a new origin connection. One third to one half did so even at 300 ms gaps; that second observation has no documented cause. With OriginKeepaliveTimeout raised to 60, 0 of 8 requests at 6-second gaps opened a new connection. The cdn-upstream-* metrics only appear on cache misses, so keepalive tests need an uncached path.

The trade-off: a longer keepalive means fewer TLS handshakes on an HTTP/1.1 hop that cannot multiplex. Whether idle origin connections keep Web Function environments alive longer, and therefore billed for memory longer, is neither documented nor measured here.

Origin Region#

No other setting moved latency as much. Direct origin TTFB from the client machine was 118 to 126 ms for us-east-1 and 19 to 26 ms for eu-central-1. With the origin in eu-central-1, keepalive at 60 seconds, and partial rendering on, navigations through CloudFront took 36 to 63 ms. Document TTFB was 28 to 32 ms, and the /stream route delivered its bursts at 72, 1,084 and 3,071 ms. eu-central-1 is not in the launch announcement’s Region list (us-east-1, us-east-2, us-west-2, ap-northeast-1, eu-west-1). aws lambda-web worked there during this trial; confirm it on the regional availability page before relying on it. For users spread across continents, the documentation describes multi-Region endpoints, which this trial did not test.

Override Cases by Setting#

The settings above form one default; the table names the case that overrides each of them.

SettingDefault in this setupOverride when
Cache keyPath, all query strings, normalized Accept-Encoding; cookies reach the origin via AllViewerExceptHostHeaderA route must vary at the edge per cookie (rare; send private instead)
Edge TTL0 unless the origin sends s-maxage or max-agePrerendered or slow-changing routes: set s-maxage on the response
Layout revalidationshouldRevalidate returns false on root and layouts (root still revalidates after form submissions)Layout data changes on child navigation or after mutations, or the 8.5.1 patch is unacceptable
CompressionIn the app, with flush optionsThe function is CPU-bound and payloads are small
Origin RegionThe Region closest to most usersUsers spread globally: multi-Region endpoint (documented, not tested here)
Origin keepalive60 secondscdn-upstream-connect is already 0 on most misses at real request gaps
Endpoint authApplicationManaged behind CloudFront (trial only)Any production front door: IamAuth with OAC

Errors and Symptoms in Build Order#

Each item is the message or symptom as observed, followed by the fix that held.

Build fails with @vitejs/plugin-rsc 0.5.36 to 0.5.38

Error: Vite RSC assets manifest not found

Fix: pin @vitejs/plugin-rsc to 0.5.35 until @react-router/dev 8.5.2 ships the fix for #15671.

One "use client" barrel file

Symptom: about 42 KB gzipped on every page, including pages that use none of the components, because the root layout imported the navigation from that file.

Fix: one client module per component.

Shim passes the build’s default export as the handler

TypeError: handler is not a function

Fix: pass build.fetch to createRequestListener.

NODE_ENV unset when the build loads (no error)

Symptom: the /dash/a document is 17,786 bytes instead of 5,471, the Flight payload carries rows such as "/server/index.js",6481,17, links trigger full page loads, and the console stays clean.

Fix: process.env.NODE_ENV ??= "production" before a dynamic import() of the server build.

Streamed responses arrive uncompressed

Symptom: no content-encoding on HTML and .rsc responses at the viewer.

Fix: compress in the app with Z_SYNC_FLUSH and BROTLI_OPERATION_FLUSH.

Action returns data(null, { headers })

TypeError: Cannot read properties of null (reading 'name')

Fix: return redirect() with the Set-Cookie header so the action response is never rendered.

Per-request values in module scope

Symptom: under 200 parallel requests, 127 to 128 responses carry another user’s name in most runs, 9 in one, 0 in one.

Fix: keep per-request values in loader scope and treat a run with zero wrong answers as inconclusive.

Layout re-renders despite shouldRevalidate

Symptom: the layout timestamp changes on every child navigation when the layout was entered through a link.

Fix: add shouldRevalidate: route.shouldRevalidate to the object returned by getManifestRoute in both index-react-server.js builds, persisted as a package patch until upstream ships it.

502 on every non-asset path through CloudFront

502 ERROR The request could not be satisfied
x-cache: Error from cloudfront

Fix: a custom cache policy without Host in the key, with AllViewerExceptHostHeader as the origin request policy.

Navigations slower through CloudFront than direct

Symptom: about 330 ms through the distribution against 125 to 132 ms direct, with cdn-upstream-connect above zero on most requests.

Fix: raise OriginKeepaliveTimeout (60 seconds in the trial) and confirm with the Server-Timing header.

What Differs With TanStack Start#

TanStack Start marks Server Components as experimental; the trial used @tanstack/react-start 1.169.4, @tanstack/react-router 1.170.46, nitro 3.0.260610-beta, and @vitejs/plugin-rsc 0.5.38. The hosting contract above is the same; what changes is where the pieces sit.

Routes are client components. Server components come from a server function, called from the loader, that returns renderServerComponent(<X />) from @tanstack/react-start/rsc. The layout skip is staleTime: Infinity on the layout route; the default of 0 reruns its server function on every child navigation. In hosting terms every server function call is an origin request, so staleTime is the setting that plays the role of shouldRevalidate in React Router. Revisited routes render from the router cache with a background refetch, measured at 6 to 8 ms.

Nitro bundles React’s production build into .output, so there is no NODE_ENV trap, and node_modules holds only compression and its dependencies. The deploy directory of .output, index.js, and compression came to about 2.9 MB against about 45 MB for the React Router app. Cold start against package size was not measured on Web Functions, so that difference says nothing about startup here.

// vite.config.ts
nitro({
  preset: 'node-middleware',
  serveStatic: true,
  // Stand-in for prerendering, which fails with this preset ("Failed to fetch /about"):
  // render on demand and let the CDN keep it 5 min; browsers always revalidate.
  routeRules: { '/about/**': { headers: { 'cache-control': 'public, max-age=0, s-maxage=300' } } },
}),

The node-middleware preset exports a handler instead of listening, so an index.js can bind AWS_LAMBDA_HTTP_ENDPOINT and put compression in front. Nitro’s Node adapter (srvx) calls res.end(callback), and compression 1.8.2 then fails:

TypeError [ERR_INVALID_ARG_TYPE]: The first argument must be of type string or an instance of Buffer, ArrayBuffer, or Array or an Array-like Object. Received function

The shim normalizes the arguments before handing the response to the middleware.

// srvx (Nitro's Node adapter) calls res.end(callback); compression 1.8.2 patches end(chunk,
// encoding) and crashes with Buffer.from(function). Normalize the arguments and keep the callback.
function withEndCallback(res) {
  const end = res.end;
  res.end = (chunk, encoding, cb) => {
    if (typeof chunk === "function") [cb, chunk] = [chunk, undefined];
    else if (typeof encoding === "function") [cb, encoding] = [encoding, undefined];
    if (cb) res.once("finish", cb);
    return end.call(res, chunk, encoding);
  };
}

Beyond the shim, server functions and prerendering behaved differently from React Router. A server function used only by a client component must also be imported from a server route module. Otherwise the call fails with Error: Server function info not found for <id> as an HTTP 500. Prerendering with the node-middleware preset failed with Error: Failed to fetch /about:. The config above uses a route rule with s-maxage instead.

The last one affects benchmarking. TanStack waits for all Suspense boundaries before sending HTML when the User-Agent looks like a bot. With AllViewerExceptHostHeader forwarding the viewer’s User-Agent, CloudFront substituted User-Agent: Amazon CloudFront only on requests that carried none. UA-less health checks and probes therefore saw a fully buffered page at about 3.1 s, while browsers streamed with first bytes at 41 ms through CloudFront. Without an origin request policy that forwards User-Agent, the custom-origin page says CloudFront replaces it on every request. TanStack would then buffer every page, browsers included. Forward the header, and benchmark streaming with a browser User-Agent.

Where This Shape Holds#

This setup holds for an RSC app whose users sit mostly in one Region and whose personalized routes are few and marked private. The shape is a Web Function in that Region, the shim above, a cache policy keyed on path and query, a 60-second origin keepalive, and shouldRevalidate on root and layouts. The override cases are the ones in the table. An origin that must be unreachable except through CloudFront calls for IamAuth with OAC; users spread across continents call for the multi-Region endpoint this trial did not test; a CPU-bound function with small payloads can drop in-app compression. The first thing to run after any deploy is the chunk-timing probe through CloudFront with a browser User-Agent. Compression and framework settings can turn streaming back into buffering without raising an error.

References#

Related posts