Describe the problem
Nothing bounds what a server-function call may send. There is no limit on the request body, on the number of arguments, or on the length of the ?args= query — no constant, no option, no default. The only bounds in the runtime are MAX_GET_URL_LENGTH (client-side, to choose GET vs POST), the error header's 1 KB cap, the codec's depth limit, and JSON_SAFE_DEPTH_LIMIT — none of which is a size.
Every argument payload is buffered and decoded before dispatch, so the cost is paid before any application code can decline it.
body: 1 arg of 32 MB of text -> 200 52ms {"argc":1}
body: 200,000 arguments -> 500 5ms
The first row is the memory story: 32 MB accepted and decoded, and nothing in the framework would have objected to 320. The second is cheaper and nastier. The argument list is spread into the call (serverFunction(...parsed)), so past V8's argument limit — around 124,600 on this machine; 100,000 still passes — the spread itself throws RangeError: Maximum call stack size exceeded. It reproduces on async () => "ok", so it is not about what the function does: a few hundred KB reliably forces a 500 out of any server function in the app.
Steps to reproduce
# Run against a build of the `next` BRANCH. The published `next` dist-tag is
# 2.0.0-rc.4, which predates the `<endpoint>/data/<id>` address (#3094) and
# answers 404 to every request below.
node repro.mjs
repro.mjs:
import { AsyncLocalStorage } from "node:async_hooks";
globalThis[Symbol.for("solid.RequestContext")] = new AsyncLocalStorage();
const srv = await import("@solidjs/web/server-functions/server");
srv.registerServerFunction("sink", async (...args) => ({ argc: args.length }));
const post = async (label, body) => {
const started = Date.now();
const response = await srv.handleServerFunctionRequest(new Request("http://x/_server/data/sink", {
method: "POST",
body,
headers: {
"Sec-Fetch-Site": "same-origin",
"X-Server-Function-Instance": "i",
"X-Server-Function-Format": "8"
}
}));
console.log(label.padEnd(34), "->", response.status, `${Date.now() - started}ms`,
(await response.text()).slice(0, 24));
};
await post("body: 1 arg of 32 MB of text", JSON.stringify(["x".repeat(32 * 1024 * 1024)]));
await post("body: 200,000 arguments", JSON.stringify(Array.from({ length: 200000 }, (_, i) => i)));
Output on next (e2b21041) — the block above.
Where the neighbours draw the line
Everyone else ships a default, and the two that matter most ship a small one:
- Next.js —
experimental.serverActions.bodySizeLimit, default 1 MB: "By default, the maximum size of the request body sent to a Server Action is 1MB… It can take the number of bytes or any string format supported by bytes, for example 1000, '500kb' or '3mb'."
- SvelteKit —
BODY_SIZE_LIMIT, default 512 K, Infinity to disable (adapter-level).
- TanStack Start —
MAX_PAYLOAD_SIZE = 1_000_000, but on the GET path only; POST is unbounded, like here.
- Rate limiting is delegated to the host by all of them, which is the right call and a different question from a payload ceiling.
Expected behavior
A call that is obviously too large is refused before it is buffered, by a default the deployment can raise.
Options
- A default byte ceiling with an option to change it, checked against
Content-Length before the body is read and enforced while reading when the length is absent. Matches what Next.js and SvelteKit ship, and it is the only option that helps the deployment that never thought about this — which is the one that needs helping. The number is the argument: 1 MB matches Next.js, and any default breaks somebody's file upload on first upgrade.
- An argument-count ceiling, separately. It is a different failure — the 200,000-argument row costs almost nothing to send and produces a guaranteed
500 — and a byte limit set for uploads would not stop it. Cheap to add: the decoded argument list is right there before the spread.
- Document the delegation. State that the runtime bounds nothing and the deployment must configure its proxy. Honest, free, and worth doing whatever else happens — but a
nginx default of 1 MB is not something an app author is likely to have checked, and serverless hosts differ wildly.
- Leave it to
wrapInvocation. It cannot help: the hook runs after the body is buffered and decoded, which is where the cost already went.
(1) and (2) are different problems and I would take both; (3) is the floor.
A note on scope
This is not the same as the depth cap being opt-out, which is #3112's third case — that is a bound that exists and can be bypassed. This is the absence of one.
Describe the problem
Nothing bounds what a server-function call may send. There is no limit on the request body, on the number of arguments, or on the length of the
?args=query — no constant, no option, no default. The only bounds in the runtime areMAX_GET_URL_LENGTH(client-side, to choose GET vs POST), the error header's 1 KB cap, the codec's depth limit, andJSON_SAFE_DEPTH_LIMIT— none of which is a size.Every argument payload is buffered and decoded before dispatch, so the cost is paid before any application code can decline it.
The first row is the memory story: 32 MB accepted and decoded, and nothing in the framework would have objected to 320. The second is cheaper and nastier. The argument list is spread into the call (
serverFunction(...parsed)), so past V8's argument limit — around 124,600 on this machine; 100,000 still passes — the spread itself throwsRangeError: Maximum call stack size exceeded. It reproduces onasync () => "ok", so it is not about what the function does: a few hundred KB reliably forces a500out of any server function in the app.Steps to reproduce
repro.mjs:Output on
next(e2b21041) — the block above.Where the neighbours draw the line
Everyone else ships a default, and the two that matter most ship a small one:
experimental.serverActions.bodySizeLimit, default 1 MB: "By default, the maximum size of the request body sent to a Server Action is 1MB… It can take the number of bytes or any string format supported by bytes, for example1000,'500kb'or'3mb'."BODY_SIZE_LIMIT, default 512 K,Infinityto disable (adapter-level).MAX_PAYLOAD_SIZE = 1_000_000, but on the GET path only; POST is unbounded, like here.Expected behavior
A call that is obviously too large is refused before it is buffered, by a default the deployment can raise.
Options
Content-Lengthbefore the body is read and enforced while reading when the length is absent. Matches what Next.js and SvelteKit ship, and it is the only option that helps the deployment that never thought about this — which is the one that needs helping. The number is the argument: 1 MB matches Next.js, and any default breaks somebody's file upload on first upgrade.500— and a byte limit set for uploads would not stop it. Cheap to add: the decoded argument list is right there before the spread.nginxdefault of 1 MB is not something an app author is likely to have checked, and serverless hosts differ wildly.wrapInvocation. It cannot help: the hook runs after the body is buffered and decoded, which is where the cost already went.(1) and (2) are different problems and I would take both; (3) is the floor.
A note on scope
This is not the same as the depth cap being opt-out, which is #3112's third case — that is a bound that exists and can be bypassed. This is the absence of one.