Replies: 2 comments
|
Follow-up after reading the server-components design notes, which already cover part of this. The server-component request grouping seed describes the same idea for server components: N same-tick invocations could ride one request, since the response side already multiplexes. The prototype follows the rule stated there, that the batch shares the pipe but never the completion, and avoids the trap it names: the batch response is never buffered, so each member's answer streams as soon as it starts. That seed is on hold because the heavy cases are covered (t=0 by the document, mutations by single-flight), and because the persistent connection in Stage 8 would make the question moot. Stage 8 isn't built yet, and client-driven reads after load, such as navigation, filters, or widgets mounting together, aren't covered by the document or by single-flight. That is the case this targets. If Stage 8 lands, batching could ride its connection instead of competing with it. The router-side data batching note is a different kind of batching: one function called with many arguments, coalesced into one call in the router, with nothing on the wire. This proposal is about different functions sharing one request, so the two can coexist. A correction to my post: the latency numbers were measured against Vite's dev server, which is HTTP/1.1. In production over HTTPS, browsers use HTTP/2 or HTTP/3, the six-connection limit doesn't apply, and the latency gain mostly disappears (75 ms against 73 ms for 12 reads in a local benchmark without the limit). What batching still saves there is per-request server work: one pass through session lookup, auth, and middleware instead of N, and one serverless invocation instead of N. I haven't measured that on a real host yet, and that measurement is what should decide whether this is worth doing. One design question the notes raise: the grouping seed assumes grouped calls share one request scope, so siblings awaiting the same query dedupe. The prototype instead runs each member as its own request with its own event, which keeps CSRF gating, |
|
Uh oh!
There was an error while loading. Please reload this page.
A page that mounts several independent reads sends one HTTP request per server function call. Over HTTP/1.1 the browser runs at most six at a time per host, so the seventh read waits for a free connection, and every request pays its own per-request cost on the server: session lookup, auth middleware, and serverless invocation. I prototyped opt-in batching that sends reads issued together as one request while each answer still streams back to its caller separately. I'd like feedback on the shape before going further.
What it looks like
A function opts in at its declaration, and the app opts in once on the client:
No server code is needed:
handleServerFunctionRequestanswers<endpoint>/batchitself. Calls are written and consumed exactly as before.How it works
/_server/batch. A lone call still goes to its own data address. The response is a framed stream: each member's status, headers, and body chunks are relayed as they happen, so every caller gets its ownResponse, a fast call never waits for a slow one, and a streamed result still streams.handleServerFunctionRequestonce per member, as a realRequestwith the outer request's options and itsCookie,Origin, andSec-Fetch-*headers. A member cannot supply its own. CSRF gating,createEvent, andwrapInvocationtherefore apply per call exactly as they do unbatched. Response metadata that lives in headers (redirects, the unknown-function label, single-flight data) reaches the caller unchanged.{ batch: true }and is notGET-declared. The declaration is read from the binding it was made about, the same ruleGETfollows (AGET()declaration outlives the function it was made about #3129), so a rebound id does not inherit it. Without this check, a hand-built batch of 32 calls to a login function would get around rate limits keyed on that function's URL.enableBatching()follows theenableRichArguments()pattern: it is its own entry, and apps that never import it carry none of the batching code. The core transport holds only the small hook it installs into.Why a declaration and not "batch everything"
Browsers apply
Set-Cookieonly from a real HTTP response, so a batched response cannot deliver cookies. If every POST joined a batch, login, logout, and session-refresh functions would break, but only when they happened to coincide with another call. That makes for an intermittent bug. With the declaration, only a function's author can make it batchable, and a declared function that sets a cookie fails with an error naming it. It is never re-sent, because it has already run.GET(fn)reads stay out even when marked, so they keep their cacheable URL. If the server has no batch route (a 404 or 405), nothing ran, and each call is sent again on its own.I also looked at inferring reads from the call site: batch calls made while a computation is tracking, and never from actions. It is feasible, but it misses router preloads, which are the biggest bursts, and it brings back the intermittent cookie failure.
Numbers
These come from a demo dashboard with 12 widgets plus one streamed feed, all loaded in the same tick. Each HTTP request pays a simulated 250 ms per-request cost, and the numbers are from Chromium against the dev server and a production preview:
Over HTTP/2 the connection limit goes away. A Node benchmark without it measured 75 ms against 73 ms for 12 reads, so what remains is one request's worth of server overhead instead of twelve. Large results pay for the framing: a 4 MiB result was 15–40% slower batched.
Questions
fetchandprepareRequesthooks, but only with both hooks taken over, and without the server-side declaration check.query()wraps reads by definition, preloads included, so it could mark what it wraps and apps would batch without annotating anything.withMetais currently typed(fn: any, meta: any): any, so wrapping a function erases its type. That's worth fixing either way.The prototype has 17 tests covering the behavior above, plus a demo app with a request waterfall. I can open it as a draft PR if the direction looks right.
All reactions