RunnableGraph.Run is called against a live
ActorSystem.
Why
Raw actor messaging requires you to handle buffer overflow, message loss, and backpressure by hand: every actor that consumes faster or slower than its peers becomes a coordination problem you have to solve case-by-case. Streams handle all of this automatically, giving you a safe and composable programming model on top of actors so you can focus on what flows through the pipeline rather than how to keep it from breaking under load.Overview
Core abstractions
A minimal pipeline looks like this:
Quick start
Linear pipeline
Use the fluentFrom / Via / To builder for straight-line pipelines:
Type-changing flows
When aFlow changes the element type, use the package-level Via free function or ViaLinear:
Sources
Sources are the origin of stream data. All sources apply backpressure: they produce only as many elements as downstream demands.Finite sources
Channel source
Bridges an external Go channel into the pipeline. Backpressure naturally limits how fast the goroutine sends:Actor source
Pulls elements from a GoAkt actor using thePullRequest / PullResponse[T] protocol. Useful for integrating existing
actor-based producers:
Tick source
Emits the current time on a fixed interval. Runs indefinitely untilStop or Abort is called:
Network source
Reads[]byte frames from a net.Conn. Each Read call produces one element; demand controls how many reads
are batched per upstream request:
Reference
Flows
Flows are lazy transformation stages. Each flow operator returns a newFlow value; multiple flows can be chained
without materializing anything.
Mapping and filtering
Streaming flat-map
When the per-element expansion is itself a stream (e.g. paginated API calls, per-row database queries), use the streaming flat-map operators instead ofFlatMap. Each input element produces a Source[Out] whose elements are
flattened into the main pipeline:
breadth elements from upstream, and
the next batch is requested only as sub-sources complete: so a slow inner source naturally throttles the outer
pipeline. Sub-pipeline handles are tracked and aborted on cancellation or failure to avoid leaking goroutines.
Windowing and rate control
Stateful flows
Parallel processing
ParallelMap applies a function concurrently with up to n goroutines. Use OrderedParallelMap when output order
must match input order:
Reference
Sinks
Sinks are the terminal consumers of a pipeline. They drive backpressure by signalling demand upstream; the pipeline does not produce faster than the sink can consume.Common sinks
Actor and channel sinks
Reference
Fan-out and fan-in
Broadcast
Broadcast fans a single source out to N independent branches. Every branch receives every element. Backpressure is
enforced by the slowest branch: the hub pulls from upstream only when all active branches have outstanding demand.
Balance
Balance distributes elements across N branches using round-robin routing with backpressure. Each element goes to
exactly one branch: the next one with available demand. Use this to parallelise work across independent consumers:
Partition
Partition routes each element to exactly one of N branches based on a user predicate. Out-of-range or
already-cancelled slot results are dropped silently. Backpressure is conservative: the hub pulls from upstream only
when every active branch has outstanding demand, so the destination slot for any incoming element is guaranteed
capacity.
Unzip
Unzip splits a single source into two sources of potentially different element types. It is composed of
Broadcast(src, 2) plus a Map per branch and follows the same backpressure rules as Broadcast.
Merge
Merge fans N independent sources into a single downstream. Elements arrive in non-deterministic order; completion
happens when all inputs have completed:
Concat
Concat consumes sub-sources sequentially: the next sub-pipeline only spawns after the current one completes,
bounding in-flight cost at one sub-pipeline regardless of source count. Per-source ordering is preserved across the
boundary.
Combine (zip, two-input mixed-type)
Combine pairs elements from two sources with a combine function. It completes when either source is exhausted.
Use it when the two inputs have different element types:
Zip / ZipWith (N-input same-type)
Zip combines N same-typed sources into a stream of []T tuples; ZipWith does the same with a user combine
function. Both complete as soon as any input is exhausted.
MergePreferred
LikeMerge, but always drains the priority slot first when it has data, falling back to the lowest-indexed
non-empty slot otherwise. Useful when one input represents a higher-priority feed (e.g. a control channel) that
should be drained ahead of background traffic. Panics on out-of-range index.
MergePrioritized
Picks the next slot by weighted random over slots that currently have data. Slots with weight 0 are only selected when they are the only ones with data. Panics onweights / sources length mismatch.
MergeLatest
Emits[]T snapshots of the latest value seen on every input. Each upstream emission queues one snapshot, but the
first only fires once every input has emitted at least once. Completes when every input completes.
MergeSequence
Emits in strict ascending sequence-number order across N inputs by buffering out-of-order elements in a min-heap. Inputs must collectively produce a contiguous range of sequence numbers starting from 0. If all inputs complete with the next-expected sequence still missing, the stream fails with a missing-sequence error.Substreams (SubFlow)
Three constructors produce aSubFlow, each with different routing semantics: GroupBy partitions by a key function (one substream per distinct key, materialised lazily), SplitWhen starts a new substream when a predicate is true (the triggering element becomes the FIRST of the new substream), and SplitAfter ends the current substream after a predicate-true element (the triggering element becomes the LAST of the closing substream). Per-substream state (Scan accumulators, Deduplicate’s last-seen value, anything stateful in the per-substream chain) is genuinely independent because each substream is a real GoAkt actor pipeline materialised lazily on its first element. MergeSubstreams collapses the partitioned stream back into a flat Source[T] whose elements interleave non-deterministically across substreams while preserving order within a single substream.
GroupBy time and applied to upstream elements regardless of how many SubFlowVia calls follow: the per-substream output type can change freely without affecting routing.
Splitting on a delimiter
SplitWhen and SplitAfter take a predicate instead of a key function and produce a SubFlow[int, T] whose synthetic substream IDs increment on each rotation. They differ in where the triggering element lands:
maxSubstreams cap is generally unnecessary and 0 (unbounded) is fine.
Cardinality cap
ThemaxSubstreams parameter caps the number of concurrently-open substreams; pass 0 for unbounded. Exceeding the cap terminates the stream with ErrTooManySubstreams, so unbounded key cardinality is a visible failure rather than a silent memory leak:
Per-substream backpressure
Each substream has an independent in-flight cap (default256 elements). When a substream is at capacity, the configured OverflowStrategy decides what happens to new elements with that key. Substream feed sources acknowledge consumption to the splitter in batches (matching the flowActor watermark refill at perKeyBuffer/4), so per-key memory is bounded without per-element protocol overhead.
Drops are reported through the existing
OnDrop hook and the droppedElements metric. For tighter or more elaborate bounds (e.g. time-based windowing per key), compose a Buffer or Throttle flow inside the per-substream chain.
Per-substream error strategy
When a per-key sub-pipeline fails,SubstreamErrorStrategy controls how the splitter reacts. The default mirrors the FailFast contract of linear flows; the alternatives let one substream’s failure stay isolated.
Reference
Pipeline DSL (Graph builder)
For non-linear topologies (fan-out branches that later merge, or multiple independent pipelines sharing a source) use theGraph DSL. Nodes are identified by string names and connected by referencing upstream names:
AddFlow and AddSink:
MergeInto(name, from...)interleaves elements from multiple upstream nodes (non-deterministic order).ConcatInto(name, from...)consumes upstreams sequentially, preserving per-source ordering across the boundary.
Cross-node stream refs
SourceRef[T] and SinkRef[T] are wire-portable handles to a stream endpoint actor. Pass one inside any registered remote message and a different node materialises it back into an ordinary Source[T] or Sink[T]: the same fluent builder works whether the producer and consumer share an actor system or live on opposite ends of a cluster. The model mirrors Akka StreamRefs.
Refs encode the producer node’s address (host, port, actor name), so cross-node resolution is a direct RemoteLookup against the producer’s remote server. There is no reliance on cluster-registry replication: the consumer can resolve and subscribe the moment the producer node is reachable, regardless of how loaded the cluster’s actor registry is.
Setup
Cross-node refs only work when both sides of the connection have the wire control protocol and the element type registered with their remoting layer. Without this registration the consumer’s bridge can’t subscribe (control messages won’t deserialize) or the producer’s elements can’t be serialised: symptoms include hangingRun calls, stream-level "no serializer found for message type ..." errors, and elements silently routed to dead-letter.
Required on every node that participates:
stream.RemoteOptions()registers the small subscribe / request / complete / error / cancel control-plane wire types. Append it to your existingremote.NewConfigoptions on every node: producer side, consumer side, and any node that might hold or forward a ref. Forgetting it on either side is the most common cause of hangs: the bridge sendsstreamSubscribeWireand the endpoint never sees a recognisable message because its remoting layer can’t decode the type.- Element types must be registered with
remote.WithSerializables(new(MyEvent)): same as for any otherrctx.Tellto a remote PID. Refs do not wrap elements in an envelope, so missing element-type registration on the producer side fails the send, and missing it on the consumer side fails the receive.
- Asymmetric registration. The producer registers
MyEventbut the consumer does not (or vice versa). The end with the missing registration logs a"no serializer found"warning when the message arrives; for the consumer that means elements never reach the local sink. Always register the same element types on both ends. - Registering the value type as a non-pointer.
remote.WithSerializableskeys on the exact type passed in: typically*MyEventfromnew(MyEvent). Sending a value-type element over the wire still works because the bridge’swireFormhelper auto-wraps non-pointer values into a*Tregistered key, but if you pass a struct value (instead ofnew(...)or an interface pointer) intoWithSerializablesitself, the registry stores the wrong type and lookups miss. Stick to thenew(MyEvent)form. - Skipping
stream.RemoteOptions()on a forwarding-only node. Any node that may receive aSourceRef[T]/SinkRef[T]inside another message (even just to inspect or relay it) needsstream.RemoteOptions()registered, because the ref struct itself is serialised through the same registry. A node missing it will fail to deserialize incoming refs. - Disabling remoting entirely. Cross-node refs require
actor.WithRemote(...)on both ends. Without itref.Source(sys)/ref.Sink(sys)materialise a bridge that can’t reach the remote endpoint and surfaces an"actor not found"resolution error after the retry budget expires. - Producer node unreachable at resolve time. Refs carry the producer node’s host:port, so resolution is a direct
RemoteLookupagainst the producer’s remote server: there is no dependency on async cluster registry propagation. If the producer node is briefly unreachable when the consumer resolves (network blip, just-restarting node), the bridge retries with jittered exponential backoff for up to 10s. After that it surfaces a"resolve source ref"/"resolve sink ref"error rather than hanging: handle it in the receiving side just like any remote-Tell failure.
remote.WithSerializables round-trips automatically, just as it would for any other rctx.Tell to a remote PID.
SourceRef: publish a producer
A SourceRef[T] exposes a local Source[T] to a remote consumer. The ref is a plain serialisable value: wrap it in any user-defined message type you like (registered with the same remoting layer) and ship it however your application already moves data between nodes.
ref.Source(...).Run(...). Constructing the ref is cheap.
SinkRef: publish a consumer
The mirror image: a SinkRef[T] exposes a local Sink[T] so a remote node can pipe data into it.
Semantics
- Single subscription per ref. A second
ref.Source(...)/ref.Sink(...)materialisation observes a stream-level error ("already subscribed"while the first stream is active,"already consumed"after it finishes). Refs are one-shot, matching Akka StreamRefs. - Wire-level credit. The consumer’s downstream demand drives
streamRequestWireto the producer; the producer ships exactly that many elements before pausing. A bounded pending queue (1024 elements) on the producer side converts a slow-consumer scenario into a visible"backpressure overflow"stream error rather than unbounded buffering on the producer node. - Bounded lifecycle. After the stream completes (success or error), the endpoint actor stays alive for a 30-second grace window so a racing late subscriber gets a clean rejection rather than telling a dead actor and hanging. After the grace window the endpoint reaps itself via
system.ScheduleOnce. Refs that are never consumed are reaped at actor-system shutdown. - Death detection. Bridges
Watchtheir remote endpoint as defense in depth: an unexpected endpoint death (panic, system shutdown mid-stream, expired grace on a stale ref) surfaces as a stream error on the bridge’sStreamHandle.Err(), never a hang. Combined with retry-with-jitter resolution that absorbs cluster propagation latency, this keeps cross-node graphs reliable under load.
Reference
Backpressure
GoAkt Streams uses credit-based demand propagation. The sink signals how many elements it can accept; each stage propagates that demand upstream. Sources produce only what is requested.StageConfig):
The sliding-window watermark means:
- When
credit > RefillThreshold, no refill is sent: the pipeline is healthy. - When
credit ≤ RefillThreshold, the stage requestsInitialDemand - creditmore elements from upstream. - This keeps in-flight data bounded while keeping the pipeline saturated.
Error handling
Each stage can be configured with an independentErrorStrategy:
Overflow strategies
Overflow strategies apply when a stage’s internal buffer is full:Stage fusion
Adjacent stateless stages (Map, Filter) are automatically fused into a single actor, eliminating intermediate
mailbox enqueues and reducing allocations.
Fusion is transparent: the external API and semantics are identical. Disable it with
FuseNone when profiling
individual stage throughput.
StreamHandle lifecycle
RunnableGraph.Run returns a StreamHandle for controlling the pipeline at runtime:
Observability
Metrics
StreamHandle.Metrics() returns a live snapshot of element counts:
Tracer
Attach aTracer to any Flow or Sink for per-element distributed tracing:
Configuration reference
StageConfig carries per-stage configuration. Apply it via the builder methods on Flow, Sink, or Source:
Comparison with other approaches
Go channels and goroutines
Go channels are the native concurrency primitive. They are low-level, composable, and require no dependencies: but they demand that the developer hand-craft every concern that GoAkt Streams handles automatically.
When to prefer channels: simple point-to-point pipelines with one producer and one consumer, or when you need
the absolute minimum overhead and are comfortable managing error propagation manually.
When to prefer GoAkt Streams: multi-stage transformations, fan-out/fan-in topologies, variable-rate sources,
anything requiring windowing, batching, or rate limiting, and whenever you want a managed lifecycle.
RxGo
RxGo is a Go implementation of Reactive Extensions (ReactiveX). It offers a rich operator library and a familiar API for developers coming from RxJS or RxJava.
Choose RxGo if you are building standalone Go applications, want the full ReactiveX operator vocabulary, and do
not need backpressure or actor integration.
Choose GoAkt Streams if you need guaranteed backpressure, are already in a GoAkt system, or require supervised
stages with automatic restart.
Benthos / Redpanda Connect
Benthos (now Redpanda Connect) is a production-grade stream-processing engine focused on connecting data systems. It is configuration-driven and ships with hundreds of connectors.
Choose Benthos if you are building a standalone data pipeline product, need ready-made connectors to external
systems, or want configuration-driven deployments without writing Go code.
Choose GoAkt Streams if stream processing is one facet of a larger GoAkt application, you want to express pipelines
in idiomatic Go code, or you need deep integration with the actor supervision model.
Apache Kafka Streams
Kafka Streams is a JVM client library for building stateful stream processors on top of Apache Kafka.
Choose Kafka Streams when you need durable, replayable, horizontally scalable stream processing on the JVM with
Kafka as the backbone.
Choose GoAkt Streams for in-process stream processing embedded in a Go service, with no external broker dependency
and tight integration with GoAkt actor supervision.
Akka Streams
Akka Streams (Scala / Java) is the direct inspiration for GoAkt Streams. Both implement the Reactive Streams specification.
GoAkt Streams deliberately mirrors Akka Streams’ core concepts: lazy
Source, Flow, Sink, demand-driven
backpressure, stage fusion: while adapting them to Go idioms and the GoAkt actor hierarchy. The PullRequest /
PullResponse actor source protocol is analogous to Akka’s Source.actorRefWithBackpressure.
Related
- Observability: Metrics and OpenTelemetry integration
- Event Streams: Internal system and cluster event bus
- PubSub: Application-level topic-based pub/sub
- Actor Scheduling: Timer and cron scheduling used by
TickandBatch - Supervision: Actor failure handling that underpins stream stage recovery