4.2 Streams, queues, and the quota-aware sender
Why ReadableStream bodies always throw under keepalive, and the batching queue recipe that moves many events through one pool slot.
Intermediate
Two engineering problems grow out of the quota: a stream-shaped one (why the platform refuses the most natural body type) and a batching one (how to move many small events through one pool slot). This page takes them in order, then assembles the answer into the queue recipe that real telemetry SDKs are built around.
Why ReadableStream is refused
The IDL accepts a stream —
BodyInit includes
ReadableStream, so the call type-checks — but the extraction algorithm refuses it one step later:
"If keepalive is true, then throw a TypeError" is the entire rule, and beacons always set it (part three triggered the throw live). The reason is structural: a keepalive request must be re-sendable after its origin page dies, and a stream can only be read once — so the platform refuses at the boundary instead of losing data later, with an honest, synchronous
TypeError at call time.
The workaround is not a workaround at all — it is the intended shape: buffer the stream yourself, then send the bytes.
await stream.text() or a reader loop materializes the content, and a string or
Blob body is quota-countable, retryable, and legal. The service-worker page of this series meets the same rule from the other side, when forwarding intercepted requests whose bodies are streams.
Batching: one beacon, one pool slot
Events of a few hundred bytes are the normal case, and sending each as its own beacon wastes pool slots and OS sockets. The fix is to batch: accumulate stringified events, and flush them as a single JSON-array body — one request, one slot, one Content-Type. The design questions are when to flush (before the buffer would cross the budget), what to do when the pool says no (keep the batch, never retry inline), and how to measure (UTF-8 bytes via
new Blob([s]).size, never
string.length — a string of emoji measures 2 UTF-16 units but 4 UTF-8 bytes, and the quota counts bytes).
Output
Ready to run.
Reading the output: the burst phase pre-flushes a full batch mid-stream (the pre-flush fires
before adding the event that would overflow, so the buffer never splits an event); the settle phases simulate requests completing and releasing their slots; the refused flush lands in
overflow instead of vanishing; and the drain recovers it once the pool refills. The injectable
send function is what makes the whole recipe testable — the demo runs the real §4.6 arithmetic inside the mock, and the same class runs against
navigator.sendBeacon in production without changing a line.
Quick check
Why does the queue measure sizes with new Blob([s]).size instead of s.length?
const chunk = JSON.stringify(event);
const size = ; // must equal the wire bytes
Key takeaways
-
Streams type-check but always throw: extraction refuses
ReadableStreamwhen keepalive is true, because a one-shot stream cannot be re-sent after the page dies. -
Buffer streams yourself —
await stream.text()— then send the bytes as a string or Blob. - Batch small events into one JSON-array body: one beacon, one pool slot.
-
Pre-flush before the budget would be crossed; measure in UTF-8 bytes with
Blob.size. -
On
false: keep the batch and drain later — the pool refills only as in-flight requests complete.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.