Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~15 min read

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).

Interactive Runnable example
Edit

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 ReadableStream when 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.
Was this page helpful?

Mark this learning resource complete to track your learning progress.