Why architecture matters here

Streaming bugs are lifecycle bugs. The naive version of any pipeline — load the list, map over it, write results — dies at scale (OOM), and the imperative streaming version that replaces it scatters lifecycle logic everywhere: who closes the file when parsing throws? who commits Kafka offsets when the process is SIGTERMed mid-batch? who stops the polling loop when the downstream write fails? Each question is a try/finally in someone's hand-rolled loop, and each missed one is a leak or a data-loss incident. fs2's architecture answers them structurally: resources bind to scopes, scopes close deterministically in every exit path, and failure or interruption anywhere in the graph propagates cancellation through it with finalizers running in order.

The pull model matters because it makes backpressure the default physics rather than an opt-in protocol. In a push-based system (callbacks, actor pipelines, raw reactive publishers), a fast producer must be explicitly told to slow down, and every hop needs buffer-and-signal machinery. In fs2, the consumer's pull is the signal: an HTTP response streaming from http4s pulls from your Stream exactly as fast as the client reads bytes; a Kafka sink pulls records exactly as fast as the broker acknowledges. Unbounded intermediate state requires deliberately asking for it (an explicit buffer or unbounded queue), which inverts the usual failure mode — you must opt in to the dangerous thing.

Chunking matters because it is the answer to the standard objection — 'functional streaming is slow'. Element-at-a-time pull through an interpreted structure would indeed crawl; pulling 4,096-element array-backed chunks makes the interpreter overhead per element vanish while preserving element-level semantics (map, filter, and friends see logical elements; the machinery moves arrays). Knowing where chunk boundaries come from — the source's natural batching (socket reads, Kafka polls) — and when an operator degrades to singleton chunks is the difference between a pipeline that saturates disks and one that mysteriously runs 40× slower after an innocent refactor.

Advertisement

The architecture: every piece explained

Top row: the core machine. Stream[F, A] is algebra over the Pull monad: a Pull either outputs a chunk, evaluates an effect, or awaits more from an upstream — a demand-driven coroutine. Combinators compose Pulls; nothing executes. Chunks are the currency: immutable, array-backed slices with O(1) indexing; sources emit whatever batch shape is natural (a 64KB socket read becomes one chunk of bytes; a Kafka poll becomes one chunk of records) and boundaries are preserved through mapping operators. compile is the terminal: compile.drain (run for effects), compile.toList (collect — only for known-small streams), compile.fold; it instantiates the root scope and runs the pull loop as one F[_] value, which is where Cats Effect's runtime, cancellation, and tracing take over.

Middle row: lifecycle and concurrency. Scopes are the invisible tree: Stream.resource and bracket register finalizers in the enclosing scope; scopes open as the pull enters that region of the graph and close — running finalizers in reverse — when it leaves, whether by completion, error, or interruption. The concurrency vocabulary is small and total: merge races two streams' outputs; parEvalMap(n) evaluates an effect per element with n in flight while preserving output order (parEvalMapUnordered relaxes it); concurrently runs a daemon stream (heartbeats, commit loops) that is cancelled when the primary finishes; broadcastThrough fans one stream through several pipes. Underneath, Queues and Topics (from CE + fs2) provide explicit handoff and pub-sub when the graph genuinely needs a join point, and interruptioninterruptWhen, timeouts, or downstream cancellation — cuts through the tree cancel-safely, closing scopes on the way.

Bottom rows: the practical surface. fs2-io wraps files, TCP sockets, TLS, UDP, and process pipes as streams with correct chunking and resource handling — a TCP server is literally Stream[F, Socket[F]], one element per accepted connection, each socket scoped to its handler. The ecosystem completes it: fs2-kafka (consumer/producer streams with offset-commit streams), http4s (request and response bodies are fs2 byte streams end to end), doobie (query results as streams — cursor-backed, constant memory). The ops strip holds the tuning levers: chunk sizes at sources, concurrency limits on parEvalMap, and metrics hooks (observe/evalTap taps that count and time without disturbing the graph).

fs2 — pull-based streams over Cats Effectchunked, resource-safe, backpressured by constructionStream[F, A]description of emissionPull monaddemand-driven coreChunksamortized per-element costCompilefold into F effectScopesresource lifetimesConcurrency opsmerge, parEvalMap, concurrentlyQueues + Topicshandoff + broadcastInterruptionscoped cancellationfs2-ioTCP, files, TLS, processesEcosystemfs2-kafka, http4s, doobie streamsOps — chunk sizing + concurrency limits + stream observabilityopen/closeforkhandoffcancelsocketsintegrateconsumeoperateoperate
fs2: pull-driven streams emit chunks through scoped resources; compile folds the description into one Cats Effect program.
Advertisement

End-to-end flow

Build the canonical pipeline: consume a Kafka topic of order events, enrich each via an HTTP service, write to Postgres, commit offsets — forever, safely. The graph: fs2-kafka's KafkaConsumer.stream emits chunks of records (one chunk per poll — natural batching). parEvalMap(16) calls the enrichment API with sixteen requests in flight, order preserved so offset commits stay monotonic. A doobie batch-insert per chunk writes Postgres. evalTap increments metrics. Offsets flow to a commit sink that batches commits every 500 records or 5 seconds — expressed with groupWithin, the operator that chunks by count or time, whichever first. The whole thing is concurrently a health-server stream, compiled and run by IOApp.

Watch the physics at runtime. Postgres slows during a vacuum: batch inserts take 400ms instead of 40. The insert stage pulls less often; parEvalMap's sixteen slots fill and stop pulling; the Kafka stage stops polling; the broker accumulates lag — the pipeline shed load upstream to the system designed to buffer it, with zero code dedicated to that behavior. Total in-process memory: sixteen in-flight enrichments plus one chunk per stage, unchanged. Dashboards show consumer lag rising and per-stage timings (from the evalTap metrics) pointing at the insert stage; nobody gets paged for memory.

Now the enrichment service starts failing. The retry policy — retryingOnSomeErrors around the effect inside parEvalMap — absorbs transient 503s with jittered backoff; a persistent failure propagates as a stream error. Failure semantics are structural: the error cancels the graph; scopes close in reverse — in-flight HTTP calls cancelled, the doobie transaction rolls back, uncommitted offsets are simply not committed, the Kafka consumer's finalizer leaves the group cleanly. The IOApp restarts the stream under supervision (a Stream.retry with capped exponential backoff around the whole pipeline); on resume, the last committed offset replays the small uncommitted window — at-least-once, delivered by construction. SIGTERM during deploy takes the same path: cancellation, finalizers, clean group exit, no rebalance storm. The pipeline's correctness story is a list of properties the library guarantees, not a list of edge cases someone remembered.