Why architecture matters here

The reason streaming needs its own architecture is that the three properties users actually want are in tension when you build them by hand. Constant memory demands you never materialize the whole source; correctness demands finalizers run on every exit path including cancellation; and throughput demands you batch work rather than paying per-element dispatch. A hand-rolled loop that reads a file line by line gets constant memory but terrible throughput and usually leaks the handle when an exception fires mid-loop. A 'collect into a List then process' version gets throughput and simplicity but OOMs on a large input. Reactive-streams libraries get all three but smear the semantics across dozens of operators with subtle rules about when onComplete fires relative to cancellation. ZStream's value is that one interpreter enforces all three laws uniformly over every stream you can build.

Concretely, several guarantees fall out of the pull-based, effectful design. Backpressure is automatic: because the sink pulls chunks and the source only produces when pulled, a database writer that can absorb 5k rows/s throttles a Kafka source reading at 50k rows/s without a single line of flow-control code — demand simply doesn't arrive faster than it can be served. Resource safety is total: ZStream.acquireReleaseWith and ScopedRef tie a source's lifetime to the stream's; the connection, file, or consumer closes on normal end, on error, and on interruption, in reverse acquisition order. Failure is typed and short-circuiting: an error in any stage propagates through the channel, runs finalizers, and surfaces as the stream's E — no swallowed exceptions hiding in a background thread.

The cost is the same as ZIO's: an interpreter sits between your code and the CPU, and you must understand chunking and where operators fork fibers to reason about memory and latency. A team that treats ZStream as 'a fancy Iterator' will be blindsided by a buffer that grows unbounded or a grouped that inflates latency; a team that understands the pull-and-chunk model gets flat memory and predictable tails for free. The architecture rewards knowing the machine.

Advertisement

The architecture: every piece explained

The unifying primitive is ZChannel, and understanding it dissolves most confusion. A channel reads a sequence of input elements plus a terminal done value, and writes a sequence of output elements plus its own done value — it is a typed, resource-safe, effectful transducer. ZStream[R,E,A] is a channel that reads nothing and writes Chunk[A]; ZSink[R,E,A,L,Z] is a channel that reads Chunk[A] and writes leftovers L with a summary result Z; ZPipeline[R,E,A,B] is a channel from Chunk[A] to Chunk[B]. Because they are all the same underlying thing, composition is just channel composition — stream >>> pipeline >>> sink — and the resource and error laws are proven once at the channel level.

Top row of the diagram: the user-facing trio. ZStream is the effectful producer — from an iterator, a queue, a Kafka consumer, repeatZIO, or unfoldChunk. Chunk[A] is the transport unit: an immutable, array-backed sequence with O(1) indexed access and specialized primitives (no boxing for Chunk[Int]); operators map and filter chunk-at-a-time, which is why throughput stays high. ZPipeline packages reusable transformations — utf8Decode, splitLines, map, mapZIO — so an ingestion stage becomes a named, testable value. ZSink folds the stream to a result: ZSink.foreach for side effects, ZSink.collectAll, ZSink.foldLeft, or a database batch-insert sink; the sink decides when it has had enough and can signal completion.

Middle row: the semantics. Pull means the sink calls the channel's pull, which pulls its upstream, all the way to the source; a chunk of demand flows up and a chunk of data flows down. Scope and finalizers: any effect that acquires a resource registers a finalizer in the stream's scope; the scope closes when the run completes for any reason, so cleanup is structural, not something you remember to do. Interruption: because a running stream is a ZIO effect on fibers, interrupting it (a timeout, a lost race, a shutdown) stops the source at the next pull boundary and runs finalizers — a stalled HTTP read is cancelled and its socket closed.

Bottom rows: the power tools. Concurrency operators fork fibers under the hood: mapZIOPar(n) runs up to n effectful transformations concurrently while preserving output order; mergeAll and flatMapPar interleave multiple substreams; buffer(capacity) decouples producer and consumer speeds through a bounded queue (bounded is the point — it preserves backpressure). Rate control shapes flow: throttleShape/throttleEnforce apply token buckets, groupedWithin(n, dur) batches by size-or-time (the classic micro-batch for DB writes), and aggregateAsync folds with a sink concurrently with upstream production. The ops strip names what you actually tune: chunk sizes, buffer bounds, emitted metrics, and how the stream drains on shutdown.

ZStream — pull-based, chunked, resource-safe streaming built on ZChannelbackpressure by construction, effects as valuesZStream[R,E,A]effectful producerChunk[A]batched elementsZPipelinestream transducerZSink[R,E,A,L,Z]folding consumerZChannelthe shared corePull semanticsdemand travels upstreamScope + finalizersacquire-release safetyInterruptioncancel stops the sourceConcurrencymapZIOPar, mergeAll, buffer, HubRate controlthrottle, groupedWithin, aggregateAsyncOps — chunk sizing + buffer bounds + metrics + graceful drainrunemittransformfoldforkdemandbatchoperateoperate
ZStream: a pull-based producer of Chunks, transformed by ZPipelines and consumed by ZSinks, all compiled onto ZChannel with scoped finalizers and interruption.
Advertisement

End-to-end flow

Trace a realistic pipeline: consume a Kafka topic of order events, enrich each against a pricing service, batch-insert into Postgres, and never use more than a bounded amount of memory regardless of topic backlog. The stream is one composed value that runs as a scoped effect inside a ZIO application.

Source: ZStream.fromQueue over a Kafka consumer, or a library Consumer.plainStream, emits Chunk[Record] — the consumer is acquired with a finalizer that commits offsets and closes on stream end. Demand pulls records in chunks of, say, 512; nothing is read from the broker faster than the downstream can accept. Decode: a ZPipeline parses each record's JSON to a domain Order, mapping parse failures into the typed error channel so a poison message fails the stream loudly (or is routed to a dead-letter sink by either/catchAll) rather than silently vanishing.

Enrich: mapZIOPar(16) calls the pricing service for each order. This forks up to sixteen fibers, so sixteen HTTP calls are in flight at once, but the operator preserves input order and — critically — still respects backpressure: it will not pull a seventeenth order until one of the sixteen completes, so a slow pricing service throttles Kafka consumption end to end. Each call carries a .timeoutFail; a hung call is interrupted, its socket closed by the HTTP client's finalizer, and the failure surfaces typed. Batch: groupedWithin(500, 2.seconds) collects enriched orders into chunks of up to 500 elements or whatever accumulated in two seconds — bounding both the write batch size and the worst-case latency for a trickle of traffic.

Sink: a ZSink.foreach that runs one batched INSERT ... ON CONFLICT per group inside a transaction, then the stream commits the corresponding Kafka offsets — offsets after the write, so a crash re-processes rather than loses. Now the failure and shutdown behavior, which is the whole point. A backlog of ten million events does not blow memory: pull keeps only chunks-in-flight plus the bounded batch buffer resident. The pricing service degrades to 200ms/call: mapZIOPar's backpressure automatically slows Kafka consumption; lag rises, memory stays flat, no crash. A malformed record at offset N: the parse pipeline fails, finalizers run (consumer closes, offsets up to N-1 already committed), the supervising ZIO retries the stream with a Schedule, and on restart consumption resumes from the last committed offset. Deployment SIGTERM: the stream is interrupted, the current batch either completes-and-commits within a drain window or is left uncommitted for reprocessing, the consumer's finalizer commits and closes, and the pod exits clean. Every one of these behaviors is a consequence of pull + chunk + scope, not code you wrote for each case.

Step back and notice what made all of that automatic. The memory bound came from pull — chunks are produced only on demand, so the resident set is bounded by chunks-in-flight plus the explicit batch buffer, no matter how many billions of events sit upstream. The backpressure came from the same pull mechanism composing through mapZIOPar, so a slow dependency throttles the source without flow-control code. The correct cleanup came from Scope, so every source and connection closed on end, error, and interruption alike. And the at-least-once delivery came from ordering the offset commit after the durable write. Not one of those four guarantees required a line of bespoke machinery in the handler — they are properties of the ZStream model, and that is precisely the leverage the abstraction buys.