docs/src/content/docs/guides/advanced/streams-internals.mdx
Reference for anyone — human or agent — changing the stream transport. The user-facing API is in Streams; this page is about the machinery underneath and the reasoning behind it, because several decisions look arbitrary until you know what they are avoiding.
| file | role |
|---|---|
v3/pkg/application/stream.go | public API, StreamConn, streamSink, the manager and its registry |
v3/pkg/application/stream_session.go | one page load in one window: the outbound queue, frame kinds, connection table |
v3/pkg/application/stream_transport.go | the two HTTP endpoints, binary framing, chunk reassembly, runtime prelude |
v3/pkg/application/stream_server.go | -tags server only: real WebSocket sink |
v3/pkg/application/stream_prelude_{server,desktop}.go | picks the client transport at bundle-serve time |
v3/internal/runtime/desktop/@wailsio/runtime/src/stream.ts | the WebSocket-shaped client |
v3/tests/stream-performance/ | load harness (-upload, -reloads, scenario sweeps) |
Go→JS and JS→Go use different mechanisms, and that asymmetry is the whole design.
Go webview
── ───────
Send() ─► per-window queue ─────────► GET /wails/stream/poll (held open)
└─ one held request per window,
carrying frames for every connection
Receive() ◄─ per-conn inbox ◄──────── POST /wails/stream/send (one or more frames)
Go→JS is a held poll. The request parks until there is something to deliver. There is no polling interval and nothing adaptive, deliberately: the server holds until a frame exists, so delivery latency is already ~0, and any client-side interval could only add to it. Frames that arrive while a response is in flight accumulate and ride the next one, which makes the round trip itself the batching window — it widens as load rises without anything measuring it. Measured: 1.0 frames per response at 100/s, still 1.0 at 5,000/s, 3.4 at 20,000/s, and p99 latency falls as the rate climbs.
JS→Go uses ordinary POSTs. Sends are serialised per connection with a promise chain,
because concurrent fetch calls do not preserve order and Go relies on send order being
the order it observes. Frames accumulated behind an in-flight request are batched into the
next POST. Go appends the accepted frame or batch prefix to the connection's inbox before
responding, so the client cannot advance past bytes Go has not queued.
One poll in flight per window, multiplexing every connection. This is what makes
ordering correct by construction — one queue, one drainer, no second delivery path that
could overtake the first. It also sidesteps the HTTP/1.1 six-connections-per-host limit on
Windows, where these are real Chromium network requests against http://wails.localhost.
Each of these is a scar from the event-transport work. Removing one re-opens a measured bug.
Nothing in the Go→JS path touches the main thread. Send appends under a mutex and
returns. Events used to run their eval inline when emitted from the main thread while an
earlier goroutine emit was still queued — 4.4% of events inverted, on all three platforms.
A single queue with a single drainer cannot do that.
Nothing touches evaluateJavaScript, at any size. Splicing payload into eval source
retains host memory above a platform-specific knee: 11.6 GB on macOS and 6.2 GB on
WebKitGTK at 100 × 1 MB/sec. Streams never go near it, which is why the constant-byte-rate
sweep is flat at every frame size.
Control data travels in headers, never the body or query string. WebKitGTK 6.0 can
deliver POST bodies as query params for custom URI schemes (transport_http.go carries a
fallback for exactly that), and WebView2 caps body delivery around 2 MB.
The poll response is binary, not JSON. Frames are []byte; base64 inside a JSON
envelope would cost 33% on every frame plus a parse on the UI thread.
magic "WS1\0" | flags u8 | count u32 | count × ( connID u32 | kind u8 | len u32 | payload )
kind is data / open / close / error. There is no sequence number and no ack: a WebSocket
does not replay, and a connection that drops loses what was in flight. Emulating that is
simpler and more honest than a cursor the bounded buffer could not always satisfy.
Holding a request is safe because every webview request already gets its own goroutine.
dispatchWorkers in assetserver_webview.go is pinned at 0 with a comment naming exactly
this case; turning that pool on would need a request-lifetime bound first.
All in stream.go. They are compile-time constants, not options — there is no
Options.Streams and no per-stream setting. Changing them means editing the file.
| constant | value | what it bounds |
|---|---|---|
streamOutQueueBytes | 8 MB | bytes buffered per window awaiting collection |
streamOutQueueDepth | 256 | frames buffered per window |
streamOutQueueBytesGlobal / streamOutQueueDepthGlobal | 256 MB / 8,192 | outbound data buffered across the application |
streamInQueueBytesGlobal / streamInQueueDepthGlobal | 256 MB / 8,192 | inbound data awaiting Receive across the application |
streamMaxConnections | 256 | live connections plus queued closes in one session |
streamMaxConnectionsGlobal | 4,096 | live connections across the application |
streamOutCloseDepthGlobal | 4,096 | undelivered close notifications across the application |
streamMaxSessionsPerWindow | 16 | sessions one window may hold before a newer generation must supersede an older one |
streamMaxSessions | 1,024 | sessions across the application |
streamOutControlDepth / streamOutControlDepthGlobal | 256 / 4,096 | queued non-close control frames, per session and across the application |
streamMaxChunkSets / streamMaxChunkTotal | 256 / 4,096 | incomplete uploads per session, and parts in one upload |
streamMaxChunkBytesGlobal / streamMaxChunkPartsGlobal | 128 MB / 4,096 | chunk payload and part metadata across the application |
streamMaxChunkIDLen | 64 bytes | one client-supplied chunk-set identifier |
streamMaxResponseBytes | 1 MB | one poll response |
streamHoldTimeout | 20 s | how long an empty poll parks |
streamSessionTTL | 60 s | no poll for this long and no live connections ⇒ session is dead |
streamSessionGrace | 10 min | no poll for this long with live connections ⇒ session is dead |
streamSessionSweep | 20 s | how often the janitor looks for dead sessions |
streamMaxFrameBytes | 64 MB | one frame in either direction |
streamMaxNameLen | 256 bytes | one registered or requested stream name |
streamInQueueDepth / streamInQueueBytes | 256 / 8 MB | frames received and not yet taken by Receive |
streamOutQueueDepth is deliberately not eventQueueCapacity (64). That constant was
measured for a queue drained one eval at a time, where depth bought nothing but tail
latency. A poll drains in batches, so depth here has to cover one round trip of
production — at 5,000 frames/s and a 5 ms round trip, ~25 frames. 256 leaves room for a
burst without stalling the producer.
streamOutQueueBytes is the bound that actually matters, because 256 frames of 1 MB is
256 MB. It is the backstop on host memory when a frontend stops collecting.
Two rules interact here, and the second is easy to break by accident:
Send blocked forever and TrySend reported full
permanently. Frame size is not always the caller's choice; a struct with a []byte field
marshals to whatever it marshals to.streamMaxResponseBytes exists because of Windows. The WebView2 response writer
accumulates the entire body in memory and only hands it over in Finish, so an unbounded
response is an unbounded allocation there. Raising it does not buy Windows throughput —
measured, the Windows bottleneck is per-byte, not per-response: responses/s varies 4×
across the frame sweep while MB/s stays flat at ~90.
The inbound bound is what makes the frontend wait. On the desktop, deliver reports
fullness, the endpoint responds 429, and the client retries the same frame or unaccepted
batch suffix with bounded backoff. This avoids occupying a webview request slot while the
handler catches up. In server mode the socket read pump waits and lets TCP apply
backpressure. Without the bound, a handler slow to call Receive could grow host memory
without limit.
Control frames bypass the data caps, but have independent lifecycle bounds. Losing a
data frame under backpressure is a slow-down; losing an open ack leaves the frontend in
CONNECTING forever, and losing a close leaves it believing a dead connection is live.
Non-close controls therefore have their own bounded queue, separate from the one closes
draw on, so a burst of refused opens cannot consume the capacity an accepted connection
needs to report that it ended. Each session also reserves one close slot per accepted
connection. When that capacity is occupied, a new open receives retryable backpressure
before it is registered.
Per-session bounds also have application-wide counterparts. Without them, every
admitted session or connection could retain its full local allowance at once. Outbound
and inbound data therefore share separate 256 MiB / 8,192-frame budgets across desktop
and server transports. Live connections have a 4,096-entry budget, and undelivered close
notifications have a second one of the same size. Reaching a shared allowance applies the
same blocking Send or non-blocking TrySend behaviour as reaching a local allowance,
and every drain, receive, close, failed write, and shutdown path returns its reservation.
Those two budgets are deliberately separate rather than one allowance a connection hands
to its own close frame. Each reservation is released by exactly one owner: a connection's
slot by shutdown, which runs once, and a close frame's slot by whatever disposes of the
frame — a drain, or its session being torn down. Ownership that migrates between two
parties has to be transferred atomically, and an earlier revision that let a close inherit
the connection's slot leaked one permanently whenever teardown ran between a close being
attempted and that attempt failing.
Go frames transfer ownership; JavaScript frames are snapshotted. Go Send retains the
caller's slice until the transport writes it, so callers must not mutate or reuse that
storage after a successful call. JavaScript send() copies mutable binary inputs before it
returns, matching native WebSocket ownership semantics. The asymmetric rule avoids a
second full-frame copy inside Go while keeping the browser-facing API unsurprising.
JavaScript sending follows the WebSocket buffering contract. send() cannot block, so
an application can queue data faster than the desktop request channel accepts it, just as
it can outrun a native WebSocket. bufferedAmount includes every byte retained by that
socket and is the caller's backpressure signal; the host-side queues remain independently
bounded by the limits above. A terminal failure, peer close, or local close() releases
the retained payloads. Local close also cancels an open or data request waiting on 429
before it posts the reserved close control, so admission or receiver backpressure cannot
leave the socket stuck in CLOSING.
Chunk reassembly has a shared host-memory allowance. Each session may assemble a single frame up to 64 MiB, but that allowance must not be multiplied by every admitted session. Incomplete and retryable chunk sets therefore share a 128 MiB admitted-payload budget. Completing a set briefly retains both its parts and its contiguous assembled frame, so doubling that logical allowance still stays within the 256 MiB effective memory ceiling. Retained parts also share a 4,096-entry metadata allowance, so tiny or empty chunks cannot grow maps and slice bookkeeping without approaching the byte limit. A request that would cross either allowance receives retryable backpressure; delivery, rejection, expiry, or session shutdown returns both bytes and part entries to the shared budgets.
Polling retries only recoverable failures. Network errors, request-timeout responses
(408), early-data responses (425), backpressure (429), and server errors (5xx)
use exponential backoff from 250 ms up to 5 seconds. Other 4xx responses are protocol
or ownership failures and close the page's Streams immediately; 410 is the clean
terminal signal for a retired session. Closing the last connection aborts an in-flight
poll or backoff timer, while a connection opened during that wind-down starts one
replacement poll loop.
streamSessionTTL must stay comfortably above streamHoldTimeout, or a session would
be reaped while its own poll is legitimately parked.
If you are tuning for a workload of many small messages, the depth cap binds first; for large payloads, the byte cap does. Neither needs changing for a typical app — on macOS the defaults sustain 634,000 frames/s and 2.1 GB/s.
A session is one page load in one window, keyed by a client-generated id (like the
runtime's clientId). Sessions are created lazily by whichever request arrives first.
When a platform cannot identify the requesting window (windowID == 0), session ids are
still globally bounded but their generations are deliberately not compared: they may
belong to independent browser clients with unrelated generation counters. Those sessions
expire through close or TTL rather than superseding one another.
Three mechanisms close things, in order of how quickly they notice:
sessionStorage. The same value is mirrored in window.name, which survives reloads
when storage is disabled, and is anchored to performance.timeOrigin (or Date.now()
on older engines) so clearing both stores does not restart at one. Every request carries
the session id and generation. A poll retires only lower page generations, so server
scheduling cannot make a delayed request from the previous page look newer than its
replacement. If policy blocks both storage and window.name, ordering falls back to the
page clock and therefore depends on later pages receiving a later time origin. The manager
retains a per-window retired-generation watermark so an already in-flight request cannot
recreate the old page without retaining every historical session id. The previous
session's connections close immediately.eventPayloadStore.dropWindow.Only the first two mechanisms retire the page generation. TTL cleanup removes the idle session without advancing the retired-generation watermark: a page stops polling whenever its last connection closes, but that same still-loaded page must be able to open another stream later. A genuinely superseded generation remains blocked because the newer page's poll advances the watermark before the old session is removed.
Apple WebViews report cancelled requests. On macOS and iOS, WebKit's
stopURLSchemeTask callback cancels the matching request context, so a poll belonging to a
page that has navigated away unblocks immediately. The registry is keyed by the retained
native task identity and removes entries when request processing closes them. Linux and
Windows still expose no equivalent early-abort callback in the current bridge; there a
parked request remains until the hold expires. Rule 1 means the connection closes promptly
regardless. Cancellation otherwise surfaces as EPIPE on Linux and only at Finish on
Windows.
Stream(name) consults window._wails.streamFactory. Server builds install one that
returns a real WebSocket; webview builds leave it unset and get the poll client.
The factory must be installed before any module body runs, because generated bindings
will create streams at module scope. custom.js cannot do it — loadOptionalScript does a
HEAD request and then appends a <script> tag, so it lands far too late. Instead the
factory is prepended to the runtime bundle as it is served (stream_prelude_server.go),
which is synchronous by construction: ES module dependencies evaluate before their
importers.
If you add a third transport, put it in the prelude too. Do not be tempted back to
custom.js.
| status | |
|---|---|
| Request cancellation from the platform layer | Apple done; Linux/Windows pending — see above |
| Buffer constants as options | not done; compile-time only |
| Typed streams | not done, deliberately — frames are []byte by decision |
| Pipelining (a second poll in flight) | not done; would need ordered reassembly in JS |
| Per-connection fairness | not done — connections in a window share one queue, so a flooding connection slows its neighbours |
| JS→Go frame coalescing | done — frames accumulated behind an in-flight request are sent in bounded batches; lightly loaded connections still send one frame per POST |
| Windows throughput | ~100 MB/s, bounded by WebResourceRequested marshalling. Shared buffers (PostSharedBufferToScript) are the candidate fix; bindings exist under internal/webview2/pkg/webview2/ but are not wired into pkg/edge |
wails3 dev / Vite | works — verified with a generated vanilla-js project: the Vite dev server proxies at /, and /wails/stream/* is matched by the asset server middleware before the proxy, so streams are unaffected |
| Multi-window | untested under load, though sessions are window-scoped by construction |
A generated project imports @wailsio/runtime from npm, not from the bundled
/wails/runtime.js that the asset server serves. Under wails3 dev, Vite resolves it from
node_modules, so an app built against a published runtime will not see client-side
additions made on a branch.
While streams are unreleased, point a test app at this checkout's package:
task v3:install-runtime -- ./path/to/your-app/frontend
That rebuilds dist/ first, so it always installs current sources. Undo it with
npm install @wailsio/runtime@latest in the same directory.
Note there are two client build outputs and it is easy to rebuild one and not the
other: task v3:runtime:build:package produces the npm package's dist/ (what an app's
frontend imports), while task v3:runtime:build:assets produces
bundledassets/runtime.js (what the webview loads from the asset server). A change to
stream.ts needs both.
go test ./pkg/application/ -run TestStream -race # protocol, ordering, backpressure
go test -tags server ./pkg/application/ -run TestServerMode
pnpm --dir v3/internal/runtime/desktop/@wailsio/runtime test
The ordering test is the one that matters: eight goroutines send concurrently against a counter handed out under the queue lock, and the drained order must match the accepted order exactly. If it ever fails, the single-drainer invariant has been broken.
Load harness:
go run ./tests/stream-performance -duration 20s # full sweep
go run ./tests/stream-performance -upload -duration 10s # JS→Go matrix
go run ./tests/stream-performance -reloads 6 # connection lifecycle
On Windows it must run in the interactive console session — a plain SSH invocation dies in
session 0 with zero-length output — and the binary must be staged somewhere both the SSH
account and the console account can read, since C:\Users\<user> is ACL'd to its owner.