Repository navigation
The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently #3137
Description
Describe the bug
The no-JS leg of the server-function transport answers a form post with a redirect and puts the outcome on a flash cookie. The encoder writes the whole result plus the decoded input at full length, with no bound:
// packages/web/server-functions/src/flash.ts:81-90
export function encodeFlashCookie(url, result, input, thrown) {
const isError = result instanceof Error;
const payload = {
url,
result: isError ? result.message : result,
error: isError,
thrown: !!thrown,
input: input.map(encodeInputValue)
};
return serializeCookie(FLASH_COOKIE, JSON.stringify(payload), { secure: true, httpOnly: true });
}A cookie has a hard ceiling and no failure signal. RFC 6265bis §5.6 puts it at 4096 bytes of name=value (RFC 6265 §6.1 states the same ceiling but counts name, value and attributes, and only as a SHOULD), and every browser implements that as the ceiling: past it the whole Set-Cookie is discarded, with nothing in the response, nothing in the console and nothing on the server. The payload is JSON that then goes through encodeURIComponent, so it reaches the ceiling at roughly 1.8× the JSON's own size — every " costs three bytes.
Measured on next (d6a4a52f), a save handler answering the rows it just wrote ({id, sku, name, price, note}, about 76 bytes of JSON each):
rows | Set-Cookie | name=value | browser stores it | node http 16 KiB | 8 KiB proxy
1 | 387 | 361 | yes | ok | ok
10 | 1575 | 1549 | yes | ok | ok
28 | 4005 | 3979 | yes | ok | ok
29 | 4140 | 4114 | NO — dropped | ok | ok
50 | 6975 | 6949 | NO — dropped | ok | ok
100 | 13725 | 13699 | NO — dropped | ok | HPE_HEADER_OVERFLOW
200 | 27525 | 27499 | NO — dropped | HPE_HEADER_OVERFLOW | HPE_HEADER_OVERFLOW
status: 303
attributes: Path=/; HttpOnly; Secure
Confirmed end to end in Chrome 152 against the same handler, driving a real form post and reading the cookie back on the page after the redirect: 28 rows (a 3979-byte pair) arrives and the render shows the outcome; 29 rows (4114 bytes) and everything above produce no cookie at all — the page after the redirect is indistinguishable from one where nothing was submitted:
28 rows -> FLASH 28 rows
29 rows -> NO FLASH
30 rows -> NO FLASH
40 rows -> NO FLASH
200 rows -> NO FLASH
The mutation committed. The 303 is correct, the browser follows it, and the user sees the page they started from with no confirmation, no error, and no way to tell whether the save happened. Retrying is the natural response, and for a non-idempotent handler that is the second write.
Who hits this. The no-JS path is reachable in a default deployment — no integration has to opt in. handleServerFunctionRequest installs the handler itself for any real form post:
// packages/web/server-functions/src/server.ts:2344-2354
// Same fallback, then the built-in convention: an unconfigured app still
// gets working progressive enhancement for real form posts, while direct
// HTTP calls keep the plain response.
const handleNoJS =
options.handleNoJS !== undefined
? options.handleNoJS
: config.handleNoJS !== undefined
? config.handleNoJS
: isFormPost(request)
? defaultNoJSHandler || (defaultNoJSHandler = createNoJSHandler())
: undefined;So the audience is exactly the one progressive enhancement is for: a user with JS off or not yet loaded, submitting a form whose handler answers with more than about 2 KB of JSON. A saved row, a list of what changed, a validation report — none of these are pathological. Twenty-eight small rows is the whole budget.
The last two columns are the same failure the codebase has already met twice: past the receiver's header budget the response stops being a response at all. That is a second ceiling, well above the browser's, and it is a socket error rather than a dropped cookie.
The docstring is aware of the limit and states it as the caller's problem — "Keep in mind the 4 KB cookie budget — outcomes larger than that will not survive the round trip" (flash.ts:67-69) — but the caller here is the server function author, who never sees this module, and the runtime is the only party that knows the size before it writes.
Steps to reproduce
# Run against a build of the `next` BRANCH.
node repro.mjsrepro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
import { createServer, request as httpRequest } from "node:http";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
// An ordinary save handler answering the rows it just wrote.
const row = i => ({ id: i, sku: `SKU-${i}`, name: `Widget ${i}`, price: 1999, note: "in stock" });
let rows = 1;
srv.registerServerFunction("save", async () => ({
ok: true,
rows: Array.from({ length: rows }, (_, i) => row(i))
}));
let port;
const server = createServer(async (incoming, outgoing) => {
const chunks = [];
for await (const c of incoming) chunks.push(c);
const response = await srv.handleServerFunctionRequest(
new Request(`http://127.0.0.1:${port}${incoming.url}`, {
method: "POST",
body: Buffer.concat(chunks).toString(),
headers: {
"content-type": "application/x-www-form-urlencoded",
referer: `http://127.0.0.1:${port}/orders`
}
})
);
outgoing.writeHead(response.status, [...response.headers]);
outgoing.end();
});
await new Promise(r => server.listen(0, "127.0.0.1", r));
port = server.address().port;
// what a no-JS <form method=post action="/_server/save"> posts
const formPost = () =>
new Request(`http://127.0.0.1:${port}/_server/save`, {
method: "POST",
body: "field=value",
headers: {
"content-type": "application/x-www-form-urlencoded",
referer: `http://127.0.0.1:${port}/orders`
}
});
const socket = maxHeaderSize =>
new Promise(resolve => {
const req = httpRequest(
{
host: "127.0.0.1",
port,
path: "/_server/save",
method: "POST",
maxHeaderSize,
headers: { "content-type": "application/x-www-form-urlencoded" }
},
res => (res.resume(), resolve("ok"))
);
req.on("error", e => resolve(e.code || e.message));
req.end("field=value");
});
console.log("rows | Set-Cookie | name=value | browser stores it | node http 16 KiB | 8 KiB proxy");
for (const n of [1, 10, 28, 29, 50, 100, 200]) {
rows = n;
const response = await srv.handleServerFunctionRequest(formPost());
const setCookie = response.headers.getSetCookie()[0];
const pair = setCookie.split(";")[0];
console.log(
`${String(n).padStart(4)} | ${String(setCookie.length).padStart(10)} | ${String(pair.length).padStart(10)} |` +
` ${(pair.length <= 4096 ? "yes" : "NO — dropped").padEnd(17)} | ${(await socket(undefined)).padEnd(16)} | ${await socket(8192)}`
);
}
console.log("\nstatus:", (await srv.handleServerFunctionRequest(formPost())).status);
console.log("attributes:", (await srv.handleServerFunctionRequest(formPost())).headers.getSetCookie()[0].split(";").slice(1).join(";").trim());
server.close();Output on next (d6a4a52f) — the table above.
Expected behavior
The runtime never emits a Set-Cookie the receiver will throw away, and an outcome that cannot ride the cookie is a legible loss rather than a silent one: the render after the redirect can tell "the outcome did not fit" apart from "nobody submitted anything".
Fix
Bound the payload where it is written, dropping the least load-bearing part first, and when the outcome itself cannot fit, say so on the cookie instead of writing one that will be discarded:
--- a/packages/web/server-functions/src/flash.ts
+++ b/packages/web/server-functions/src/flash.ts
@@ -34,6 +34,11 @@ export interface FlashSubmission {
result?: any;
/** The thrown value, when the call threw. */
error?: any;
+ /**
+ * The outcome was larger than a cookie can carry and was dropped. The
+ * call still ran; `result` and `error` are both absent.
+ */
+ oversize?: boolean;
}
@@ -87,7 +92,42 @@ export function encodeFlashCookie(url, result, input, thrown) {
thrown: !!thrown,
input: input.map(encodeInputValue)
};
- return serializeCookie(FLASH_COOKIE, JSON.stringify(payload), { secure: true, httpOnly: true });
+ // A cookie has a hard ceiling and no failure signal: past 4096 bytes of
+ // `name=value` the browser drops the whole thing and nobody is told, so
+ // the render after the redirect sees no submission at all while the
+ // mutation has committed. Bound the payload here, dropping the least
+ // load-bearing part first, and when the outcome itself cannot fit say so
+ // — a legible "it did not fit" beats a silent nothing.
+ let cookie = writeFlash(payload);
+ if (pairBytes(cookie) <= FLASH_COOKIE_LIMIT) return cookie;
+ // The echo of what was submitted goes first: the render is showing the
+ // OUTCOME, and it still holds the form it posted.
+ cookie = writeFlash({ ...payload, input: [] });
+ if (pairBytes(cookie) <= FLASH_COOKIE_LIMIT) return cookie;
+ return writeFlash({ url, oversize: true, thrown: !!thrown, error: isError, input: [] });
+}
+
+/**
+ * Bytes a browser will store for one cookie: RFC 6265 §6.1 sets 4096 as
+ * the floor every user agent must accept, and every browser implements it
+ * as the ceiling too — on `name=value`, not on the whole `Set-Cookie`.
+ */
+const FLASH_COOKIE_LIMIT = 4096;
+
+function writeFlash(payload) {
+ return serializeCookie(FLASH_COOKIE, JSON.stringify(payload), {
+ secure: true,
+ httpOnly: true,
+ // Named rather than left to each browser's default. A form post and
+ // the redirect that follows it are same-site, so Lax carries the
+ // outcome and nothing else can read it back.
+ sameSite: "lax"
+ });
+}
+
+function pairBytes(setCookie) {
+ const end = setCookie.indexOf(";");
+ return end < 0 ? setCookie.length : end;
}
@@ -106,7 +146,14 @@ export function decodeFlashCookie(cookieHeader) {
if (!match) return;
try {
const payload = JSON.parse(match);
- if (!payload || !payload.result) return;
+ if (!payload) return;
+ // The outcome was past the cookie ceiling and was not carried. The
+ // submission still happened, so the render is told that much rather
+ // than being handed the shape of "nobody submitted anything".
+ if (payload.oversize) {
+ return { input: [], url: payload.url, oversize: true, result: undefined, error: undefined };
+ }
+ if (!payload.result) return;
const result = payload.error ? new Error(payload.result) : payload.result;Cost, stated plainly: no existing test changes. The server suite is 45 files green with the diff alone, and test/runtime/cookies.spec.js is untouched.
Re-measured in Chrome 152 with the diff: 28 and 29 rows now both arrive (the input echo is dropped at 29, which buys back enough), and 30 rows and up come back as oversize — a 141-byte cookie the render can act on.
The SameSite=Lax in writeFlash is not a measured failure; Chrome applies Lax by default and the flow works today. It only stops the attribute from varying by user agent. Say the word and it comes out of the diff.
Options
- Bound + degrade + report — the diff above. The redirect still works, the common case is unchanged, and the uncommon one is visible in production rather than only in a dev log. Costs a new field on
FlashSubmission, which integrations rendering flash outcomes should handle (ignoring it degrades to today's blank). - Bound + refuse the whole write. Emit no flash cookie at all past the ceiling, plus a
DEVconsole.errornaming the function and the byte count. Smaller — no wire change, no new field — but production stays silent, and silence is the actual complaint. - Bound + spill to a second cookie.
flash,flash.1, … reassembled on decode. Keeps large outcomes working, and browsers allow ~180 cookies per domain. But it multiplies the request-header cost of every subsequent request until the cookie is cleared, and the 8 KiB proxy row above says where that ends. - Answer the outcome from the server instead. A store keyed by a small id on the cookie. Correct, and out of scope — the runtime has no storage primitive and should not grow one for this.
- Document it harder. The docstring already documents it (
flash.ts:67-69) and the failure still reaches production silently, so this one is already disproved.
I would take (1), with (2) as the smaller version if the new field is unwelcome.
Adjacent and cheap, if you want it: the cookie already satisfies every __Host- requirement (Secure, Path=/, no Domain), so FLASH_COOKIE could be __Host-flash for free. It is an exported constant integrations match on, so that is a breaking rename and belongs in its own change.
Regression test
packages/web/test/server/server-functions-flash-bounds.spec.tsx — 4 of its 6 cases fail on next and all 6 pass with the diff:
/**
* The flash cookie is the no-JS leg's only channel: a form posted without
* the client runtime redirects, and the outcome rides one `Set-Cookie` for
* the next render to read. A cookie has a hard ceiling — RFC 6265 §6.1, and
* every browser implements it as 4096 bytes of `name=value` — and the
* encoder writes the whole result plus the decoded input at full length
* with no bound, so an outcome past it is emitted, dropped by the browser,
* and the render after the redirect sees nothing. The mutation committed.
*
* Same family as the error header (#3093) and the redirect/revalidate
* headers (#3131): a response field composed from author-derived content
* needs a bound, and the failure past it must be legible rather than
* silent.
*
* Like the other server-function specs, these run against the built
* bundles (server-functions/dist/*, wired up in vite.config.server.mjs).
*/
import { AsyncLocalStorage } from "node:async_hooks";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
decodeFlashCookie,
handleServerFunctionRequest,
registerServerFunction
} from "@solidjs/web/server-functions/server";
const RequestContext = Symbol.for("solid.RequestContext");
// Browsers store at most 4096 bytes of `name=value` (RFC 6265 §6.1);
// anything larger is dropped without an error anywhere.
const COOKIE_PAIR_LIMIT = 4096;
// An ordinary row, the shape a save handler answers with.
const row = (i: number) => ({
id: i,
sku: `SKU-${i}`,
name: `Widget ${i}`,
price: 1999,
note: "in stock"
});
beforeAll(() => {
(globalThis as any)[RequestContext] = new AsyncLocalStorage();
// 28 rows fits, 29 does not — measured against Chrome 152.
registerServerFunction("flash-fits", async () => ({
ok: true,
rows: Array.from({ length: 28 }, (_, i) => row(i))
}));
registerServerFunction("flash-over", async () => ({
ok: true,
rows: Array.from({ length: 29 }, (_, i) => row(i))
}));
registerServerFunction("flash-far-over", async () => ({
ok: true,
rows: Array.from({ length: 200 }, (_, i) => row(i))
}));
// A small outcome whose INPUT echo is what pushes the payload over.
registerServerFunction("flash-big-input", async () => ({ ok: true, id: 7 }));
});
afterAll(() => {
delete (globalThis as any)[RequestContext];
});
// A browser form post: a form encoding, no client-runtime format header.
function formPost(id: string, body: BodyInit = new URLSearchParams({ field: "value" })) {
return new Request(`https://app.example/_server/${id}`, {
method: "POST",
body,
headers: {
"Sec-Fetch-Site": "same-origin",
"Sec-Fetch-Mode": "navigate",
Referer: "https://app.example/orders"
}
});
}
function flashCookie(response: Response) {
const values = response.headers.getSetCookie();
return values.find(value => value.startsWith("flash="));
}
/** The `name=value` half — what counts against the browser's 4096. */
function pairBytes(setCookie: string) {
return setCookie.split(";")[0].length;
}
describe("an outcome that fits", () => {
it("rides the cookie whole", async () => {
const response = await handleServerFunctionRequest(formPost("flash-fits"));
expect(response.status).toBe(303);
const cookie = flashCookie(response)!;
expect(pairBytes(cookie)).toBeLessThanOrEqual(COOKIE_PAIR_LIMIT);
const flash = decodeFlashCookie(cookie.split(";")[0])!;
expect((flash.result as any).rows).toHaveLength(28);
expect(flash.url).toBe("/_server/flash-fits");
});
it("names its SameSite rather than leaving it to each browser's default", async () => {
const cookie = flashCookie(await handleServerFunctionRequest(formPost("flash-fits")))!;
expect(cookie).toMatch(/; SameSite=(Lax|Strict)\b/);
expect(cookie).toMatch(/; HttpOnly\b/);
expect(cookie).toMatch(/; Secure\b/);
});
});
describe("an outcome past the ceiling", () => {
it("is never emitted as a cookie the browser will drop", async () => {
for (const id of ["flash-over", "flash-far-over"]) {
const cookie = flashCookie(await handleServerFunctionRequest(formPost(id)))!;
expect(pairBytes(cookie)).toBeLessThanOrEqual(COOKIE_PAIR_LIMIT);
}
});
it("still redirects — the mutation committed either way", async () => {
const response = await handleServerFunctionRequest(formPost("flash-far-over"));
expect(response.status).toBe(303);
expect(response.headers.get("Location")).toBe("https://app.example/orders");
});
it("reports the loss instead of arriving as nothing", async () => {
const cookie = flashCookie(await handleServerFunctionRequest(formPost("flash-far-over")))!;
const flash = decodeFlashCookie(cookie.split(";")[0]);
// The render after the redirect must be able to tell "the outcome did
// not fit" from "there was no submission".
expect(flash).toBeDefined();
expect((flash as any).oversize).toBe(true);
expect(flash!.url).toBe("/_server/flash-far-over");
});
it("drops the input echo before it drops the outcome", async () => {
// The result is small; the submitted form is what does not fit. The
// outcome is what the render needs, so the echo goes first.
const body = new URLSearchParams({ blob: "x".repeat(4000) });
const cookie = flashCookie(
await handleServerFunctionRequest(formPost("flash-big-input", body))
)!;
expect(pairBytes(cookie)).toBeLessThanOrEqual(COOKIE_PAIR_LIMIT);
const flash = decodeFlashCookie(cookie.split(";")[0])!;
expect(flash.result).toEqual({ ok: true, id: 7 });
expect(flash.input).toEqual([]);
});
});(The spec sends Sec-Fetch-Mode: navigate on its form posts. That is not required by this diff — it is there so the spec keeps passing alongside the change proposed in the companion issue about isFormPost.)
Related
- An unbounded error message in the response header makes the response unreadable #3093 bounded the error header for exactly this failure mode, and
boundedErrorHeaderValue(server.ts:1887) is the shape it took. - The redirect and revalidate headers are unbounded, so a successful mutation can answer with a network error #3131 is the same gap on the redirect and revalidate headers.
This is the third instance, and the only one of the three where the receiver's limit is a cookie rather than a header block — which is why the ceiling is 4 KB rather than 8–16 KB, and why the failure is a silent drop rather than a socket error.
Environment
@solidjs/web |
next @ d6a4a52f |
| Node | v24.19.0 |
| Browser measurement | Chrome 152 |
| OS | macOS (darwin 25.6.0) |
Apply order
#3138 (cookie shapes) and #3139 (the no-JS signal) touch the same path. This issue's regression spec assumes #3139's change, so land that one first; the attribute question belongs to #3138 and is deliberately not argued here.
Activity
[-]web: the no-JS flash cookie has no size bound — an outcome past 4 KB is dropped by the browser and the render after the redirect sees nothing[/-][+]The no-JS flash cookie has no size bound — an outcome past the browser's ceiling vanishes silently[/+]fix(web): bound the flash cookie and validate unservable cookie shapes (
ryansolid commentedon Aug 31, 2026
Fixed in ecfee20: the flash encoder now degrades instead of vanishing. Past the ceiling it drops the input echo first, then bounds the value itself (a string keeps the longest prefix that fits; structured results reduce to the outcome flag), and the submission arrives with a new
truncatedfield so integrations can render "succeeded — result too large to display".urland the error/thrown flags always survive: what happened and to which submission is the part whose loss invited the double-submit. Budget is 4000 bytes for the name=value pair, which fits both the RFC 6265bis §5.6 reading and the stricter attributes-counting one. Your 29-rows Chrome measurement is pinned as a regression test shape.