Iterable Web Streams Extensions

Draft,

This version:
https://iter-web-streams.proposal.wintertc.org/
Issue Tracking:
GitHub
Editor:
(Cloudflare)

This document is not yet an official WinterTC draft. It is an individual proposal intended to be submitted to ECMA TC55 (WinterTC) for consideration. It has not been adopted, endorsed, or reviewed by the committee. The content may change substantially before or during that process.

Introduction

The Streams Standard [STREAMS] defines ReadableStream, WritableStream, and TransformStream: class-based streams with chunk-at-a-time delivery. The Iterable Streams API [ITER-STREAMS] defines a bytes-only streaming model built directly on the ECMAScript iteration protocols: streams are AsyncIterable<Uint8Array[]>, iteration yields batches of chunks to amortize per-tick costs, and production goes through a Writer interface with an explicit byte budget and backpressure policies.

This specification bridges the two models from the web streams side. It defines:

// (a) Batched reads from any ReadableStream
const reader = rs.getReader({ mode: 'iter' });
for await (const chunks of reader) {
  // chunks is Uint8Array[]
}

// (b) Push-model construction; the controller IS an iter-streams Writer
const rs = new ReadableStream({
  type: 'iter',
  async start(writer) {
    if (!writer.writeSync(header)) await writer.write(header);
    await writer.end();
  }
});

// (c) Batch-native sink; the controller IS an iter-streams readable
const ws = new WritableStream({
  type: 'iter',
  async start(source) {
    for await (const chunks of source) {
      socket.writev(chunks);
    }
  }
});

Internally, the new controller and reader types are based on the iterable streams model, allowing implementations to take advantage of its performance characteristics. Because they are new controller and reader types, backwards compatibility with existing streams is preserved: every existing operation on an iter-type stream works through compatibility facades, and every existing stream gains batched reading without modification.

Goals

  1. Batched consumption for all streams: Reading a ReadableStream batch-at-a-time amortizes async iteration cost regardless of how the stream was created.

  2. Full iterable-streams citizenship: Objects defined by this specification conform structurally to the [ITER-STREAMS] interfaces and protocols. An iter reader is a ByteReadableStream; the iter controllers implement Writer and ByteReadableStream respectively; the protocol symbols are honored. Web streams participate in the iterable streams ecosystem — Stream.pull(), Stream.pipeTo(), Stream.text(), transforms — without adapters.

  3. Backwards compatibility: An iter-type stream is a ReadableStream (or WritableStream). Default readers, default writers, tee(), piping, and every other existing operation continue to work.

  4. Pull-through piping: When both ends of a pipe are iter-type, the pipe is the iterable streams model: lazy, fused, and driven by the ultimate consumer.

  5. Bytes-only where it matters, generic where it helps: Iter-type streams are bytes-only, matching [ITER-STREAMS]. The iter reader is generic: on a value-oriented stream it still provides batching-as-amortization, without claiming iterable-streams integration.

Non-Goals

  1. Modifying the Iterable Streams API: [ITER-STREAMS] remains a separate specification. This specification consumes its interfaces and protocols; it defines nothing on the Stream namespace.

  2. Replacing default or byte streams: The default and "bytes" stream types are untouched. This specification adds a third type alongside them.

  3. New consumption utilities: Draining helpers (text(), bytes(), etc.) belong to [ITER-STREAMS]. This specification makes web streams work with them rather than duplicating them.

  4. BYOB integration: The "byob" reader mode and iter-type streams do not interact. getReader({ mode: "byob" }) on an iter-type stream throws, exactly as it does on a default stream.

Note: This specification is intended for standardization through ECMA TC55 (WinterTC), not through the WHATWG. Because Web IDL provides no mechanism for extending enumerations from outside a specification, the changes to the Streams Standard are expressed as amendments in § 15 Modifications to the Streams Standard. If the extensions prove successful, upstreaming some or all of them into the Streams Standard would be a desirable outcome; the design deliberately confines the amendments to well-isolated extension points (new enum values, reserved dictionary members, and type-dispatched algorithm steps) to keep that path open.

Design Rationale

The duality

The central design is a duality between the two stream directions and the two [ITER-STREAMS] interfaces:

Web streams side Iterable streams side
Producing into a readable ReadableStreamIterController implements Writer
Consuming from a writable WritableStreamIterController implements ByteReadableStream
Consuming a readable ReadableStreamIterReader is a ByteReadableStream
Producing into a writable WritableStreamIterWriter implements Writer

[ITER-STREAMS] states that Writer "is an interface, not a concrete class — any object implementing this interface can serve as a writer," and that its stream types are structural (AsyncIterable<Uint8Array[]>). The controllers defined here are those objects. There is no wrapping and no adapter layer between the models: the controller handed to an underlying source’s start() is a Writer; the source handed to an underlying sink’s start() is an iterable-streams readable.

Why the integration needs no changes to [ITER-STREAMS]

Three properties of [ITER-STREAMS] carry the integration:

  1. Stream.from() consults Symbol.for('Stream.toAsyncStreamable') before the iteration protocols. This specification installs that hook on ReadableStream.prototype (§ 10 The toAsyncStreamable hook), so every iterable-streams entry point — Stream.from(), Stream.pull(), Stream.text(), Stream.pipeTo() — automatically upgrades any ReadableStream to batched reads. Without the hook, web streams still work through the Symbol.asyncIterator fallback, but degrade to single-chunk batches.

  2. Stream.pipeTo() duck-types its destination on the write method (preferring writev). The controllers and writers defined here satisfy it structurally, so they are valid iterable-streams pipe destinations with batching, close, and error propagation intact.

  3. The Symbol.for('Stream.drainableProtocol') symbol makes Stream.ondrain() work with any object that carries it. The Writer-implementing objects defined here carry it.

Batch boundaries are not semantic

Batching exists to amortize iteration costs. It carries no meaning: any operation may split, merge, or regroup batches, provided the flattened chunk sequence and its ordering are preserved (§ 5.1 Batch boundaries are not semantic). This principle is what makes the compatibility facades and the fused pipe observationally coherent: unbatching for a default reader, re-coalescing at a sink, and end-to-end fusion all preserve the only thing that matters — the chunks and their order.

© 2026 Ecma International

Permission under Ecma’s copyright to copy, modify, prepare derivative works of, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the full text of this copyright notice on ALL copies of the work or portions thereof.

THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.

1. Scope

This proposal defines the Iterable Web Streams Extensions: an extension of the Streams Standard [STREAMS] that integrates it with the Iterable Streams API [ITER-STREAMS]. It specifies:

2. Conformance

A conforming implementation of this specification must also be a conforming implementation of the Streams Standard [STREAMS] as amended by § 15 Modifications to the Streams Standard, and must provide the [ITER-STREAMS] interfaces and protocol symbols to the extent this specification depends on them.

Note: An implementation need not expose the [ITER-STREAMS] Stream namespace to conform to this specification — the dependency is on the Writer interface contract, the structural stream types, the backpressure model, the transform contract, and the protocol symbols. In practice the two specifications are designed to be implemented together.

A conforming implementation shall also conform to [ECMASCRIPT] and [WEBIDL].

3. Normative references

The following documents are referred to in the text in such a way that some or all of their content constitutes requirements of this document.

References

Normative References

[DOM]
Anne van Kesteren. DOM Standard. Living Standard. URL: https://dom.spec.whatwg.org/
[ECMASCRIPT]
ECMAScript Language Specification. URL: https://tc39.es/ecma262/multipage/
[ITER-STREAMS]
James M Snell. Iterable Streams API. Draft Proposal. URL: https://iter-streams.proposal.wintertc.org/
[STREAMS]
Adam Rice; et al. Streams Standard. Living Standard. URL: https://streams.spec.whatwg.org/
[WEBIDL]
Edgar Chen; Timothy Gu. Web IDL Standard. Living Standard. URL: https://webidl.spec.whatwg.org/

4. Terms and definitions

For the purposes of this document, the terms and definitions given in [ECMASCRIPT], the Streams Standard [STREAMS], the Iterable Streams API [ITER-STREAMS], and the following apply.

4.1. Iterable Web Streams Extensions

the extensions to the Streams Standard defined by this specification

4.2. batch

an array of chunks delivered as a single iteration step or read result; for byte-oriented streams, a Uint8Array[] as defined by the [ITER-STREAMS] batched chunks concept

4.3. iter readable stream

a ReadableStream whose controller is a ReadableStreamIterController

4.4. iter writable stream

a WritableStream whose controller is a WritableStreamIterController

4.5. facade

the adaptation layer through which existing Streams Standard operations (default readers, default writers, and the algorithms built on them) interoperate with iter-type streams

4.6. fused pipe

the pull-through pipe established when both ends of a pipe operation are iter-type streams, in which the destination sink’s iteration consumes the source directly with no intermediate buffering

5. Core concepts

5.1. Batch boundaries are not semantic

A batch is a delivery grouping, not a message boundary. Operations defined by this specification — and implementations, wherever this specification grants latitude — may split, merge, or regroup batches freely. The normative invariant is the flattened chunk sequence: the sequence of individual chunks obtained by concatenating batches in order. All operations MUST preserve the flattened chunk sequence and its ordering; none are required to preserve batch boundaries.

Note: This matches [ITER-STREAMS], in which batch composition is implementation-chosen and transforms regroup freely. Applications MUST NOT attach meaning to how chunks are grouped into batches.

5.2. Bytes-only types, generic reader

Iter readable streams and iter writable streams are bytes-only: their chunks are Uint8Array objects, and their production surfaces accept Uint8Array or strings (which are UTF-8 encoded), following the [ITER-STREAMS] Writer contract. Chunks of any other type are rejected with a TypeError.

The ReadableStreamIterReader, by contrast, is generic: it may be acquired on any ReadableStream and performs no chunk coercion. On a byte-carrying stream its batches are Uint8Array[] and the reader is a conforming [ITER-STREAMS] ByteReadableStream. On a value-oriented stream, batches are arrays of whatever the stream carries — batching still amortizes iteration cost, but the reader does not claim integration with the [ITER-STREAMS] byte-oriented utilities.

5.3. Internal state: the slots buffer

An iter-type stream’s internal state is the [ITER-STREAMS] model, not the Streams Standard’s chunk queue: a slots buffer of batches, a byte budget, and a buffered byte count, governed by a backpressure policy. Each write or writev occupies one slot; a slot’s byte size is the sum of its chunks' byte lengths.

The byte budget is configured through the stream’s queuing strategy: highWaterMark is the budget in bytes. If no strategy is provided, the budget is an implementation-defined default of at least 16384 bytes, mirroring [ITER-STREAMS]. A provided value below the implementation’s minimum MAY be clamped to that minimum. A size function MUST NOT be provided for iter-type streams; if present, the constructor throws a RangeError. Byte accounting is intrinsic.

Buffered bytes are decremented as chunks leave the buffer. When a facade consumes a slot partially (chunk at a time), the buffered byte count decreases per chunk as each chunk is consumed, not when the slot empties. This keeps producer-observable backpressure signals (canWrite, the drainable protocol) consistent between batch-native and facade consumption.

Note: The [ITER-STREAMS] overshoot rule — a write is accepted whenever buffered bytes are below the budget, and then counts fully even if it overshoots — means buffered bytes can exceed the budget. Through the default-writer facade this surfaces as a negative desiredSize, which is exactly the Streams Standard’s existing behavior for an over-full queue. The two models align without adjustment.

5.4. The compatibility invariant

Every existing Streams Standard operation works on iter-type streams through the facades:

Stream default reader / writer byob reader iter reader / writer
default ReadableStream existing behavior TypeError (existing) adapt: drain queue into a batch
byte ReadableStream existing behavior existing behavior adapt: drain queue into a batch
iter readable stream adapt: unbatch (§ 8.4 The default reader facade) TypeError native
default WritableStream existing behavior adapt: Writer over default machinery (§ 12.3 On any other WritableStream: adapted)
iter writable stream adapt: writes into the slots buffer (§ 11.4 The default writer facade) native

tee(), piping between mixed types, async iteration of the stream itself (which remains chunk-at-a-time for compatibility), and integration points in other specifications that consume ReadableStream or WritableStream all operate through the default facades and continue to work unchanged.

5.5. Fusion

When both ends of a pipe are iter-type, the pipe is not a pump loop between two buffers. It is a fused pipe: the destination sink’s iteration is bound directly to the source’s batch sequence, through any composed transforms, back to the origin producer’s byte budget. Data moves only when the ultimate consumer pulls. See § 14 Piping.

6. Web IDL definitions

6.1. IterWriteOptions

dictionary IterWriteOptions {
  AbortSignal signal;
};

The IterWriteOptions dictionary mirrors the [ITER-STREAMS] WriteOptions dictionary.

6.2. The ReadableStreamIterReader interface

[Exposed=*]
interface ReadableStreamIterReader {
  constructor(ReadableStream stream);

  Promise<ReadableStreamReadResult> read();
  undefined releaseLock();
};
ReadableStreamIterReader includes ReadableStreamGenericReader;

The ReadableStreamIterReader interface provides batched reads. It is acquired via getReader({ mode: "iter" }) on any ReadableStream. See § 7 The iter reader.

6.3. The ReadableStreamIterController interface

[Exposed=*]
interface ReadableStreamIterController {
  readonly attribute boolean? canWrite;

  Promise<undefined> write((Uint8Array or USVString) chunk,
                           optional IterWriteOptions options = {});
  Promise<undefined> writev(sequence<(Uint8Array or USVString)> chunks,
                            optional IterWriteOptions options = {});
  boolean writeSync((Uint8Array or USVString) chunk);
  boolean writevSync(sequence<(Uint8Array or USVString)> chunks);

  Promise<unsigned long long> end(optional IterWriteOptions options = {});
  long long endSync();

  undefined fail(optional any reason);
};

The ReadableStreamIterController interface is the controller for iter readable streams. It implements the [ITER-STREAMS] Writer interface; its member semantics are those of the Writer interface, bound to the stream’s slots buffer. See § 8 Iter readable streams.

6.4. The WritableStreamIterController interface

[Exposed=*]
interface WritableStreamIterController {
  readonly attribute AbortSignal signal;
};

The WritableStreamIterController interface is the controller for iter writable streams. It conforms to the [ITER-STREAMS] ByteReadableStream structural type: it has a Symbol.asyncIterator method returning an async iterator that yields batches. See § 11 Iter writable streams.

The signal attribute mirrors the Streams Standard’s WritableStreamDefaultController.signal: it is aborted when the stream is aborted, allowing a sink engaged in long-running work between iteration steps to observe cancellation.

6.5. The WritableStreamIterWriter interface

[Exposed=*]
interface WritableStreamIterWriter {
  constructor(WritableStream stream);

  readonly attribute boolean? canWrite;
  readonly attribute Promise<undefined> closed;

  Promise<undefined> write((Uint8Array or USVString) chunk,
                           optional IterWriteOptions options = {});
  Promise<undefined> writev(sequence<(Uint8Array or USVString)> chunks,
                            optional IterWriteOptions options = {});
  boolean writeSync((Uint8Array or USVString) chunk);
  boolean writevSync(sequence<(Uint8Array or USVString)> chunks);

  Promise<unsigned long long> end(optional IterWriteOptions options = {});
  long long endSync();

  undefined fail(optional any reason);

  undefined releaseLock();
};

The WritableStreamIterWriter interface implements the [ITER-STREAMS] Writer interface over a WritableStream. It is acquired via getWriter({ mode: "iter" }). See § 12 Iter writers.

6.6. Options dictionaries

dictionary WritableStreamGetWriterOptions {
  DOMString mode;
  DOMString backpressure;
};

dictionary ReadableStreamFromOptions {
  DOMString type;
};

partial dictionary UnderlyingSource {
  DOMString backpressure = "strict";
};

partial dictionary UnderlyingSink {
  DOMString backpressure = "strict";
};

The backpressure members take [ITER-STREAMS] BackpressurePolicy values. They are consulted only when the corresponding type is "iter"; for other stream types they are ignored. For iter writable streams, only "strict" and "unbounded" are permitted; see § 11.3 Backpressure policy.

Note: BackpressurePolicy is defined by [ITER-STREAMS]. The members are declared as DOMString because the enumeration is defined in another specification; values are validated in prose.

6.7. Protocol conformance

Objects defined by this specification participate in the [ITER-STREAMS] protocol symbols:

7. The iter reader

7.1. Acquisition

A ReadableStreamIterReader is acquired by calling getReader({ mode: "iter" }) on any ReadableStream, regardless of the stream’s underlying source type, or by its constructor. Acquisition locks the stream, exactly as for other reader types: while the reader is active, locked is true and acquiring any other reader throws a TypeError.

Unlike mode: "byob", mode: "iter" does not constrain the stream’s controller type. The reader performs no chunk coercion; see § 5.2 Bytes-only types, generic reader.

7.2. Reading

The read() method reads the next batch. It returns a promise for a ReadableStreamReadResult:
  1. If the stream is errored, the promise rejects with the stored error.
  2. If chunks are available — in the slots buffer for an iter readable stream, or in the internal queue for other stream types — the promise resolves with { value, done: false }, where value is an array containing all currently available chunks, drained from the stream, in order. For an iter readable stream, the array MAY correspond to one or more whole or partial slots; boundaries are not preserved (§ 5.1 Batch boundaries are not semantic).
  3. Otherwise, if the stream is closed, the promise resolves with { value: undefined, done: true }.
  4. Otherwise, the read participates in the stream’s normal demand signaling (for pull-based sources, a pull is requested) and the promise resolves with a batch containing at least one chunk once data becomes available, or with { value: undefined, done: true } if the stream closes first.

Draining all available chunks per read is the batching amortization: one promise resolution delivers everything buffered. Implementations MAY cap the drained byte size of a single batch at an implementation-defined limit.

Reads decrement the buffered byte count and notify drain waiters per § 5.3 Internal state: the slots buffer, propagating demand to the producer.

7.3. Async iteration

The async iterator of a ReadableStreamIterReader is obtained via its Symbol.asyncIterator method. Its behavior:
  1. next() performs the same operation as read(), returning its result as the iterator result object.
  2. return(reason) cancels the stream with reason (as if by reader.cancel(reason)), releases the lock, and resolves with a done result.
  3. throw(reason) behaves as return(reason) and then rejects with reason.

A consumer breaking out of for await...of therefore cancels the source, matching the [ITER-STREAMS] convention that a consumer that stops iterating tears the pipeline down to the source.

When the stream’s chunks are Uint8Array objects, the reader is a conforming ByteReadableStream: it may be passed directly to Stream.pull(), Stream.text(), Stream.pipeTo(), and any other [ITER-STREAMS] consumer.

7.4. Reader generics

ReadableStreamIterReader includes the Streams Standard’s generic reader behaviors: the closed promise and cancel(reason) behave as for a default reader. releaseLock() releases the lock; pending read promises reject with a TypeError, matching default reader semantics.

8. Iter readable streams

8.1. Construction

An iter readable stream is constructed with type: "iter":

const rs = new ReadableStream({
  type: 'iter',
  async start(writer) {
    // writer is the ReadableStreamIterController — an iter-streams Writer
    if (!writer.writeSync(chunk)) await writer.write(chunk);
    const n = writer.endSync();
    if (n < 0) await writer.end();
  }
}, { highWaterMark: 65536 });

Constructor rules, applied when the underlying source’s type is "iter":

  1. start is required. If absent, throw a TypeError. The start callback is the only mechanism for acquiring the controller; a stream that can never be written to or ended is unusable by construction.

  2. If pull or autoAllocateChunkSize is present, throw a TypeError. The pull model is provided by ReadableStream.from() (§ 9 ReadableStream.from() in batch mode), not by the controller.

  3. cancel is permitted and is invoked per § 8.3 Cancellation.

  4. The queuing strategy provides the byte budget per § 5.3 Internal state: the slots buffer; a size function throws a RangeError.

  5. The underlying source’s backpressure member selects the [ITER-STREAMS] backpressure policy for the controller; the default is "strict". All four policies are permitted.

  6. start is invoked with the ReadableStreamIterController as its argument. If start throws or its returned promise rejects, the stream is errored with the thrown value, matching existing underlying-source semantics.

8.2. The controller is a Writer

The ReadableStreamIterController implements the [ITER-STREAMS] Writer interface, bound to the stream:

Chunks are accepted zero-copy: a written Uint8Array is not copied or detached, matching [ITER-STREAMS]. Producers MUST NOT mutate a chunk after writing it; see § 16 Security considerations.

8.3. Cancellation

When an iter readable stream is cancelled (via cancel(), a reader’s cancel, or iterator return):
  1. The slots buffer is discarded.
  2. The controller transitions to the errored state with the cancel reason: subsequent writes reject (or return false from sync variants), canWrite returns null, pending write promises reject, a pending end() promise rejects, and drain waiters are notified with the reason per the [ITER-STREAMS] fail() semantics.
  3. The underlying source’s cancel callback, if any, is invoked with the reason, per existing Streams Standard semantics.

This is the web-streams expression of the [ITER-STREAMS] rule that a consumer that stops iterating signals cancellation to the writer.

8.4. The default reader facade

Default readers (and every operation built on them: async iteration of the stream, tee(), mixed-type piping, and consumers in other specifications) interoperate with iter readable streams through the unbatching facade:

The flattened chunk sequence observed through the facade is identical to the sequence observed through batch reads.

9. ReadableStream.from() in batch mode

ReadableStream.from() is amended to accept an options argument: ReadableStream.from(source, { type: "iter" }).

When the type option is "iter", batch-mode from performs the following:
  1. Let iterable be source, interpreted as an async iterable if it has Symbol.asyncIterator, else as a sync iterable if it has Symbol.iterator; otherwise throw a TypeError.
  2. Return a new iter readable stream whose batch sequence is produced by lazily iterating iterable:
    1. Each value yielded by iterable that is an array is interpreted as a batch: its elements are the chunks. Each element must be a Uint8Array; otherwise the stream is errored with a TypeError.
    2. Each yielded value that is a Uint8Array is interpreted as a single-chunk batch.
    3. Any other yielded value errors the stream with a TypeError.
    4. Iteration is demand-driven: at most one iteration step of iterable is outstanding at a time, and steps are taken only to satisfy reads. Implementations MAY read ahead up to the byte budget.
    5. When iterable completes, the stream closes. If iteration throws, the stream errors with the thrown value.
    6. If the stream is cancelled, the iterator’s return() method is invoked, per existing ReadableStream.from() semantics.

When the type option is absent or undefined, ReadableStream.from() behaves exactly as currently specified (each yielded value is a single chunk of a default stream). No inspection-based batch detection is performed: an async iterable of Uint8Array[] passed without the option produces a default stream whose chunks are arrays, exactly as today.

Note: Batch-mode from() supplies the pull model for iter-type streams; type: "iter" construction with start supplies the push model. This mirrors the [ITER-STREAMS] division between Stream.from() and Stream.push(). Note the difference in element handling: Stream.from() normalizes forgivingly (strings encoded, buffers wrapped); batch-mode from() is strict, accepting only Uint8Array chunks. Applications wanting the forgiving behavior can compose the two specifications: ReadableStream.from(Stream.from(input), { type: "iter" }).

10. The toAsyncStreamable hook

ReadableStream.prototype gains a method keyed by Symbol.for('Stream.toAsyncStreamable'):

The ReadableStream toAsyncStreamable method, when invoked on a ReadableStream stream:
  1. If stream is locked, throw a TypeError.
  2. Return the result of getReader({ mode: "iter" }) on stream.

Because [ITER-STREAMS] Stream.from() consults Symbol.for('Stream.toAsyncStreamable') before the iteration protocols, this single method upgrades every iterable-streams entry point to batched reads for any ReadableStream:

// All of these consume the ReadableStream via batched reads:
await Stream.text(webReadableStream);
Stream.pull(webReadableStream, gzip);
await Stream.pipeTo(webReadableStream, writer);

Note: For value-oriented streams, the returned reader yields batches of non-byte values, which the [ITER-STREAMS] normalization rules will reject with a TypeError when consumed by its byte-oriented utilities — the same outcome as consuming such a stream through the Symbol.asyncIterator fallback.

11. Iter writable streams

11.1. Construction

An iter writable stream is constructed with type: "iter" in the underlying sink. The underlying sink’s type member is currently reserved by the Streams Standard (any value throws a RangeError); this specification defines its first value.

const ws = new WritableStream({
  type: 'iter',
  async start(source) {
    // source is the WritableStreamIterController — an AsyncIterable<Uint8Array[]>
    try {
      for await (const chunks of source) {
        await socket.writev(chunks);
      }
      await socket.close();       // iteration completed: stream is closing
    } finally {
      // runs on completion, abort (iteration throws), or sink failure
    }
  }
}, { highWaterMark: 65536 });

Constructor rules, applied when the underlying sink’s type is "iter":

  1. start is required; if absent, throw a TypeError. The consumption loop is the sink.

  2. If write, close, or abort is present, throw a TypeError. The per-chunk callback lifecycle is replaced by the iteration protocol: data arrives as batches from iteration; closure is observed as iteration completion; abort is observed as the iteration throwing.

  3. The queuing strategy provides the byte budget per § 5.3 Internal state: the slots buffer; a size function throws a RangeError.

  4. The underlying sink’s backpressure member selects the policy; only "strict" and "unbounded" are permitted — "drop-oldest" and "drop-newest" throw a RangeError. See § 11.3 Backpressure policy.

  5. start is invoked with the WritableStreamIterController as its argument.

11.2. The controller is the consumption side

The WritableStreamIterController conforms to the [ITER-STREAMS] ByteReadableStream structural type. Its async iterator:

  1. Yields, per step, a batch containing all chunks currently in the slots buffer — draining every available slot and concatenating their contents in order. This coalescing is where batching is regained even when producers wrote chunk-at-a-time; boundaries are not preserved (§ 5.1 Batch boundaries are not semantic).

  2. If the buffer is empty, waits for data.

  3. Completes (done) after the end sentinel — enqueued when the stream is closed via writer.close() or a writer’s end() — has been reached and all prior data yielded.

  4. Throws the abort reason at the suspended (or next) iteration step when the stream is aborted.

Sink lifecycle maps onto start’s settlement:

During a fused pipe, the controller’s iteration is bound to the pipe’s composed source rather than the slots buffer; see § 14 Piping.

11.3. Backpressure policy

Iter writable streams support only the "strict" and "unbounded" policies. The dropping policies are excluded: data written to a WritableStream is committed toward a sink, and silently discarding committed data is not a meaningful sink behavior. (The dropping policies remain available where [ITER-STREAMS] defines them — push streams and broadcasts — and on iter readable streams, whose controller is a push-model writer.)

The policy is a property of the stream, configured at construction. It governs all producers: iter writers natively, and default writers through the facade.

11.4. The default writer facade

Default writers (and every operation built on them, including mixed-type piping) interoperate with iter writable streams through the facade:

12. Iter writers

12.1. Acquisition

A WritableStreamIterWriter is acquired by calling getWriter({ mode: "iter" }) on any WritableStream, or by its constructor. Acquisition locks the stream exactly as acquiring a default writer does.

The backpressure option is permitted only when the stream is not an iter writable stream (see § 12.3 On any other WritableStream: adapted); on an iter writable stream the policy belongs to the stream, and specifying the option throws a TypeError.

12.2. On an iter writable stream: native

On an iter writable stream, the writer’s operations bind directly to the stream’s slots buffer, budget, and policy — the Writer semantics of [ITER-STREAMS] with no adaptation. writev() batches occupy single slots and reach the sink’s iteration whole (subject to coalescing). end() is equivalent to closing the stream and resolves with the total bytes written through this writer. fail(reason) aborts the stream.

12.3. On any other WritableStream: adapted

On a default WritableStream, the writer adapts the Writer contract onto the default machinery:

Writer surface Default WritableStream mechanism
canWrite true if desiredSize > 0; false if ≤ 0; null if the stream is closed, closing, or errored
drainable protocol derived from the ready promise, wrapped to the true / false (closed) / reject (errored) contract
write(chunk) strings UTF-8 encoded; forwarded as a single write
writev(chunks) forwarded as sequential writes, in order; the returned promise settles when all have settled. Atomicity is best-effort: if a write fails mid-batch, the stream is already errored and no further chunks are written
writeSync() / writevSync() if desiredSize > 0, forward (per write/writev above) and return true; otherwise return false and write nothing — the try-fallback signal
end() close(); resolves with total bytes written through this writer
endSync() returns −1 unless the writer is already closing or closed (sink close is asynchronous); the try-fallback signal to await end()
fail(reason) abort(reason). Iter-level pending write promises reject synchronously with reason; the abort itself settles per Streams Standard semantics

The backpressure option (default "strict") selects the admission policy the adapter applies in front of the stream’s queue: "strict" tolerates one un-awaited pending write past exhaustion before rejecting with a RangeError; "unbounded" queues without limit. The dropping policies throw a TypeError, per § 11.3 Backpressure policy.

Note: This adaptation is what makes Stream.pipeTo(source, transforms, ws.getWriter({ mode: "iter" })) fully correct for any WritableStream. A default writer half-satisfies the [ITER-STREAMS] pipe destination duck-type today (it has write but not writev, end, or fail), silently losing batching, close propagation, and error propagation. The iter writer completes the contract.

13. Iter transform streams

13.1. Construction

A batch-native TransformStream is constructed using the transformer dictionary’s readableType and writableType members — reserved by the Streams Standard, given their first values here:

const ts = new TransformStream({
  readableType: 'iter',
  writableType: 'iter',
  async *transform(source, { signal }) {
    // source is AsyncIterable<Uint8Array[] | null> — null is the flush signal
    for await (const chunks of source) {
      if (chunks === null) { yield finalize(); break; }
      yield process(chunks);
    }
  }
});

Constructor rules:

  1. readableType and writableType must both be "iter" or both absent. Mixed combinations throw a RangeError (reserved for future work).

  2. When both are "iter": the readable side is an iter readable stream; the writable side is an iter writable stream; and the transformer follows the contract below. start, flush, and cancel members throw a TypeError if present — startup work belongs to the transform itself, flush is expressed by the null flush signal, and cancellation is delivered through the abort signal and the iteration protocols.

  3. The transformer may provide exactly one of transform or transformBatch; providing both throws a TypeError. Providing neither yields an identity transform (batch pass-through).

13.2. The transformer contract

The contract is the [ITER-STREAMS] transform contract:

13.3. Laziness and composition

The transform executes pull-through: no transform code runs until the readable side is consumed. The writable side’s slots buffer is drained only as the transform’s source iterable is pulled, which happens only as the transform’s output is pulled. Backpressure spans the transform: a slow consumer of the readable side propagates, through the transform, to the writable side’s budget and its producers.

In a chain of iter-type transform streams connected by fused pipes, this composes into a single lazily evaluated pipeline — the [ITER-STREAMS] compose-transform-pipeline model expressed through web streams plumbing. See § 14 Piping.

14. Piping

14.1. Type dispatch

The Streams Standard’s pipe-to algorithm gains a type dispatch as its first step:

pipeThrough() requires no changes: it is pipe-to plus returning the readable side, and each leg dispatches independently.

14.2. The fused pipe

A fused pipe from source rs to destination ws with options (preventClose, preventAbort, preventCancel, signal):
  1. Lock rs and ws for the duration of the pipe, exactly as the existing algorithm does. Internally, rs is consumed as batches (as if through an iter reader).
  2. Bind ws’s WritableStreamIterController iteration to rs’s batch sequence: while the pipe is active, the controller’s current upstream is the source’s batch sequence instead of ws’s own slots buffer. Piped data does not enter ws’s buffer.
  3. Data flows only as the sink iterates: each sink iteration step pulls the next batch from rs, which drains rs’s slots buffer and notifies its producer’s drain waiters. Backpressure is coupled end to end with no intermediate elasticity.
  4. Batches may be split or merged in flight; the flattened chunk sequence is preserved (§ 5.1 Batch boundaries are not semantic).
  5. Shutdown propagation, byte-for-byte with the existing algorithm’s constraints:
    Event Default behavior With prevent* flag
    rs errors the sink’s iteration throws the reason; ws is errored; the pipe promise rejects preventAbort: the current upstream detaches; the sink’s iteration suspends awaiting ws’s own buffer; the pipe promise rejects
    rs closes ws is closed as if by a writer’s close(): the end sentinel follows the piped data; the sink’s iteration completes; the pipe promise fulfills after close fulfillment preventClose: the current upstream detaches; the sink’s iteration suspends awaiting ws’s buffer; the pipe promise fulfills
    ws errors (sink loop throws; start rejects) rs is cancelled with the reason; the pipe promise rejects preventCancel: rs is not cancelled; the pipe promise rejects
    signal aborted as in the existing algorithm: error both sides with the abort reason, subject to preventAbort and preventCancel; the pipe promise rejects with the reason
  6. On pipe completion for any reason that leaves ws writable, the current upstream reverts to ws’s slots buffer, so subsequently acquired writers feed the same, still-running sink iteration — the sink observes one continuous sequence stitched across pipe and non-pipe phases.

Note: Nothing in the fused pipe is a mere optimization license; the laziness is normative. An implementation MUST NOT eagerly drain the source into destination-side buffering during a fused pipe — the absence of intermediate elasticity is observable to the source-side producer through its budget, and it is the point.

Note: A chain such as rs.pipeThrough(a).pipeThrough(b).pipeTo(ws), with every stage iter-type, collapses hop by hop: each fused pipe binds a sink iteration to an upstream batch sequence, and each iter transform connects its legs lazily (§ 13.3 Laziness and composition). The result is a single consumer-driven pipeline from ws’s sink back to rs’s producer budget, semantically equivalent to a single [ITER-STREAMS] pull pipeline.

14.3. Mixed-type pipes

Mixed-type pipes use the existing algorithm through the facades. Two consequences worth noting:

15. Modifications to the Streams Standard

This section enumerates the amendments this specification makes to [STREAMS]. A runtime implementing both specifications behaves as if the Streams Standard were modified as follows:

  1. **ReadableStreamReaderMode**: add the enumeration value "iter".

  2. **getReader()**: when the options' mode is "iter", return a new ReadableStreamIterReader for the stream (§ 7.1 Acquisition). No controller-type restriction applies.

  3. **ReadableStreamType**: add the enumeration value "iter".

  4. **ReadableStream constructor**: when the underlying source’s type is "iter", set up a ReadableStreamIterController per § 8.1 Construction, including the start-required, no-pull, no-size rules and the backpressure member.

  5. **ReadableStream.from()**: accept a second argument, a ReadableStreamFromOptions; when its type is "iter", behave per § 9 ReadableStream.from() in batch mode.

  6. **ReadableStream.prototype**: add the Symbol.for('Stream.toAsyncStreamable') method defined in § 10 The toAsyncStreamable hook.

  7. **Underlying sink type**: replace the unconditional RangeError for a present type member with: if the value is "iter", set up a WritableStreamIterController per § 11.1 Construction; for any other non-undefined value, throw a RangeError as today.

  8. **getWriter()**: accept an optional WritableStreamGetWriterOptions argument; when mode is "iter", return a new WritableStreamIterWriter per § 12 Iter writers. getWriter() with no arguments is unchanged.

  9. **Transformer readableType / writableType**: replace the unconditional RangeError for present members with: if both are "iter", construct the transform per § 13 Iter transform streams; any other present, non-undefined combination throws a RangeError as today.

  10. Pipe-to: prepend the type dispatch of § 14.1 Type dispatch; when both ends are iter-type, the pipe is the fused pipe of § 14.2 The fused pipe.

  11. Default reader and writer interoperation with iter-type streams per § 8.4 The default reader facade and § 11.4 The default writer facade.

No other Streams Standard behavior is modified. In particular, async iteration of a ReadableStream itself remains chunk-at-a-time; tee() is unchanged (operating through the default facade on iter streams); and the "bytes" type, BYOB machinery, and default streams are untouched.

16. Security considerations

17. Open issues

17.1. Mixed transform leg types

readableType: "iter" with a default writable side (or the converse) is currently a RangeError. Mixed legs are coherent in principle — the facades would carry them — but the transformer contract for a mixed transform is unclear. Deferred.

17.2. Stateless transformer member name

transformBatch was chosen because transform is claimed by the stateful form for [ITER-STREAMS] shape compatibility. Better names may exist.

17.3. Budget floor

This specification mirrors the [ITER-STREAMS] 16384-byte budget floor by permitting implementations to clamp smaller highWaterMark values. Whether small explicit budgets should instead be honored (useful for testing) is an open question for both specifications.

17.4. Upstreaming

The amendments in § 15 Modifications to the Streams Standard are confined to enum values, reserved dictionary members, and a type dispatch in pipe-to, specifically to keep an upstreaming path into [STREAMS] open. Whether and when to propose that is a process question outside this document.

Index

Terms defined by this specification

Terms defined by reference

IDL Index

dictionary IterWriteOptions {
  AbortSignal signal;
};

[Exposed=*]
interface ReadableStreamIterReader {
  constructor(ReadableStream stream);

  Promise<ReadableStreamReadResult> read();
  undefined releaseLock();
};
ReadableStreamIterReader includes ReadableStreamGenericReader;

[Exposed=*]
interface ReadableStreamIterController {
  readonly attribute boolean? canWrite;

  Promise<undefined> write((Uint8Array or USVString) chunk,
                           optional IterWriteOptions options = {});
  Promise<undefined> writev(sequence<(Uint8Array or USVString)> chunks,
                            optional IterWriteOptions options = {});
  boolean writeSync((Uint8Array or USVString) chunk);
  boolean writevSync(sequence<(Uint8Array or USVString)> chunks);

  Promise<unsigned long long> end(optional IterWriteOptions options = {});
  long long endSync();

  undefined fail(optional any reason);
};

[Exposed=*]
interface WritableStreamIterController {
  readonly attribute AbortSignal signal;
};

[Exposed=*]
interface WritableStreamIterWriter {
  constructor(WritableStream stream);

  readonly attribute boolean? canWrite;
  readonly attribute Promise<undefined> closed;

  Promise<undefined> write((Uint8Array or USVString) chunk,
                           optional IterWriteOptions options = {});
  Promise<undefined> writev(sequence<(Uint8Array or USVString)> chunks,
                            optional IterWriteOptions options = {});
  boolean writeSync((Uint8Array or USVString) chunk);
  boolean writevSync(sequence<(Uint8Array or USVString)> chunks);

  Promise<unsigned long long> end(optional IterWriteOptions options = {});
  long long endSync();

  undefined fail(optional any reason);

  undefined releaseLock();
};

dictionary WritableStreamGetWriterOptions {
  DOMString mode;
  DOMString backpressure;
};

dictionary ReadableStreamFromOptions {
  DOMString type;
};

partial dictionary UnderlyingSource {
  DOMString backpressure = "strict";
};

partial dictionary UnderlyingSink {
  DOMString backpressure = "strict";
};

Ecma International

Rue du Rhone 114

CH-1204 Geneva

Tel: +41 22 849 6000

Fax: +41 22 849 6001

Web: https://ecma-international.org/

© 2026 Ecma International

This draft document may be copied and furnished to others, and derivative works that comment on or otherwise explain it or assist in its implementation may be prepared, copied, published, and distributed, in whole or in part, without restriction of any kind, provided that the above copyright notice and this section are included on all such copies and derivative works. However, this document itself may not be modified in any way, including by removing the copyright notice or references to Ecma International, except as needed for the purpose of developing any document or deliverable produced by Ecma International.

This disclaimer is valid only prior to final version of this document. After approval all rights on the standard are reserved by Ecma International.

The limited permissions are granted through the standardization phase and will not be revoked by Ecma International or its successors or assigns during this time.

This document and the information contained herein is provided on an "AS IS" basis and ECMA INTERNATIONAL DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY OWNERSHIP RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.

Software License

All Software contained in this document ("Software") is protected by copyright and is being made available under the "BSD License", included below. This Software may be subject to third party rights (rights from parties other than Ecma International), including patent rights, and no licenses under such third party rights are granted under this license even if the third party concerned is a member of Ecma International. SEE THE ECMA CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT https://ecma-international.org/memento/codeofconduct.htm FOR INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO IMPLEMENT ECMA INTERNATIONAL STANDARDS.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
  3. Neither the name of the authors nor Ecma International may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL ECMA INTERNATIONAL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.