Flow & Backpressure
A fast producer feeding a slow consumer is one of the classic ways a concurrent program falls over. The producer reads a million-row file or accepts a flood of requests; the consumer does something slow with each item; and the queue between them grows without bound until memory runs out. Flow is March’s answer: a backpressure-aware pipeline where the consumer sets the pace and the producer only ever runs as far ahead as the consumer has asked for.
Flow.from_list([1, 2, 3, 4, 5])
|> Flow.map(fn x -> x * 2)
|> Flow.filter(fn x -> x > 4)
|> Flow.collect
-- [6, 8, 10]
What backpressure is, and why you want it
Backpressure means demand flows backward: consumers pull, producers push only on request. Instead of a producer shoving items into an unbounded buffer and hoping the consumer keeps up, each stage emits only as many items as the next stage has demanded. If the consumer slows down, the demand dries up, and the producer naturally throttles — no buffer blowup, no out-of-memory, no silent backlog.
Use Flow when:
- You’re processing a stream that doesn’t fit in memory — log lines, database rows, an HTTP response body, sensor readings.
- A producer can outrun a consumer and you need the system to self-regulate rather than buffer unboundedly.
- You want a reusable, composable pipeline (
≥ 3stages) you can plug into different sources and sinks.
Don’t use Flow for a one-off transformation of a small in-memory list — reach for List.map / Enum.map there. And for stateful, bidirectional workflows, talk to an actor directly.
The producer → transformer → consumer model
Every pipeline has three kinds of stage:
producer ──► transformer ──► transformer ──► consumer
▲
demand flows ◄──────────────
- Producers generate items:
Flow.from_list,Flow.from_seq,Flow.range,Flow.unfold. They emit lazily — nothing is computed until a downstream stage demands it. - Transformers reshape the stream:
Flow.map,Flow.filter,Flow.flat_map,Flow.take,Flow.drop,Flow.batch. They’re lazy too, and chain with the pipe operator. - Consumers run the pipeline. They are the only eager operations — they pull items through and force evaluation:
Flow.collect,Flow.reduce,Flow.run,Flow.each,Flow.count,Flow.find.
A Stage(a) is the value flowing through the pipe — a lazy sequence of as. Producers create one, transformers return a new one, consumers turn one into a result.
Core API
Producers
Flow.from_list([1, 2, 3, 4, 5]) -- from an in-memory list
Flow.from_seq(some_seq) -- from an existing lazy Seq
Flow.range(0, 1000000) -- the half-open range [0, 1000000)
-- Unfold a seed: next(seed) -> Option((item, new_seed)); stop on None.
Flow.unfold(0, fn s -> if s >= 5 do None else Some((s * s, s + 1)) end)
-- produces 0, 1, 4, 9, 16
Flow.range produces a million integers without ever materialising a million-element list — they’re generated on demand as the consumer pulls.
Transformers
Flow.from_list(words)
|> Flow.map(fn w -> String.to_lowercase(w))
|> Flow.filter(fn w -> String.byte_size(w) > 3)
|> Flow.flat_map(fn line -> some_flow_of_tokens(line))
|> Flow.take(100)
flat_map’s function returns a Stage, whose items are flattened into the output stream.
Consumers
|> Flow.collect -- gather everything into a List
|> Flow.reduce(0, fn (acc, n) -> acc + n) -- fold to a single value
|> Flow.run -- run for side effects, discard items
|> Flow.count -- how many items
|> Flow.find(fn x -> x > 100) -- first match, or None
with_concurrency
Flow.from_list(urls)
|> Flow.map(fn url -> Http.get(url))
|> Flow.with_concurrency(8)
|> Flow.collect
with_concurrency(stage, n) is a hint: in the compiled runtime it asks the scheduler to run this stage across n worker actors. In the interpreter it’s a no-op (see the next section). It does not change which items come out — only how many are computed at once.
Interpreter vs. compiled: two backends, one API
Flow has the same shape everywhere, but the engine underneath differs:
- In the interpreter (
march run, the REPL), aStage(a)is a lazySeq(a)— a fold-based, church-encoded sequence. “Demand” is implicit: the consumer’s fold simply stops calling the producer’s fold once it has enough (viaSeq.take/ a bounded fold). It is single-threaded, sowith_concurrencyis a no-op. The results are exactly what the compiled pipeline produces — only the parallelism is absent. - In the compiled runtime, each
Stageis backed by an actor. The consumer sends aDemand(n)message upstream; each stage forwards demand to its source, buffers at mostnitems, and pushes them downstream. This is real, message-driven backpressure: the demand protocol is what keeps a fast producer from outrunning a slow stage.with_concurrency(n)spawnsnworker actors for the stage and round-robins demand across them.
So you develop and test against the interpreter — correct results, easy to reason about — and get backpressure and parallelism for free when you compile.
Coming from Elixir: the GenStage comparison
Flow is modeled on Elixir’s GenStage/Flow. If you know that world:
| Feature | March Flow |
Elixir GenStage / Flow |
|---|---|---|
| Core model | Stage wraps a lazy Seq (interpreter) / actor (compiled) |
GenServer stages with an explicit demand protocol |
| Backpressure | implicit (lazy) in the interpreter; actor Demand(n) when compiled |
explicit :ask / :events demand messages |
| Parallelism | with_concurrency(n) hint |
Flow.partition / Flow.from_stages |
| Stateful stages | stateless transformers; use an Actor for state |
GenStage {:noreply, events, state} |
| Error handling | Result values carried in the pipeline |
:noreply or supervisor restarts |
Flow corresponds to Elixir’s high-level Flow library (composed stages), not the low-level GenStage building block (where you hand-write producer/consumer callbacks). Fine-grained min_demand / max_demand tuning and key-based partition are GenStage features March Flow doesn’t expose yet; for stateful key-routed fan-out, drop down to an actor.
Where Flow fits among the concurrency tools
Flow is the bounded streaming member of March’s concurrency toolkit. The decision guide in Actors → Choosing a concurrency primitive places it against actors, Task.*, and List.pmap. The short version:
Use
Flowwhen an unboundedTask.async_streamwould let a fast producer overwhelm a slow stage.Task.async_streamover a giant collection spins up work for every item at once; if a downstream stage is the bottleneck, the in-flight work piles up.Flowis the bounded alternative — the consumer’s demand caps how far ahead the producer runs.
If your data is a fixed list and each item is independent and roughly equal cost, List.pmap / pmap_n is simpler. Reach for Flow when the work is a multi-stage stream and one stage can fall behind.
See also
- Actors — the actors and scheduler the compiled
Flowbackend is built on, plus the concurrency decision guide. - Parallel Collections —
pmap/pmap_nfor data-parallel collections. - Supervision Trees — a worked job processor that adds
Flowbackpressure in front of a pool of supervised workers.