+1 (417) 281-3175

Kafka Streams or Flink: Choosing a Stream Processor You Can Operate

The question arrives in roughly this form: "we're standardising on a stream processor — Kafka Streams or Flink?" It is usually framed as a feature comparison, and the feature lists are close enough that the comparison does not decide anything. Both do stateful joins, windowed aggregations, event-time semantics with watermarks, and exactly-once writes back into Kafka. Both are mature. Both are free.

The decision is not about features. It is about who owns the state, who owns the scheduler, and what the failure looks like at 3 a.m. Here is how we work through it with teams.

The structural difference

Kafka Streams is a library. You add a dependency to a JVM service, write a topology, and deploy that service the way you deploy every other service — container image, Kubernetes deployment, your existing CI, your existing dashboards. There is no cluster to run. Parallelism comes from the consumer-group protocol: instances join a group, tasks are assigned per input partition, and scaling out means running more pods. State lives in RocksDB on local disk next to each instance, backed by a compacted changelog topic in Kafka.

Flink is a distributed runtime. A JobManager coordinates, TaskManagers execute, and your job is an artifact submitted to that runtime. Parallelism is a job-level setting the scheduler assigns across task slots, independent of your topic's partition count beyond the source operator. State lives in a state backend — usually RocksDB on the TaskManager — and durability comes from periodic distributed checkpoints and operator-triggered savepoints written to object storage.

Almost every practical difference below follows from those two sentences.

Where the differences actually bite

Parallelism and the partition ceiling

Kafka Streams parallelism is bounded by partition count, exactly like any consumer group. A topology reading a 12-partition topic will never usefully run more than 12 instances of that sub-topology, and increasing the partition count later on a keyed topic is the painful change we have written about before. If one stage of your pipeline is twenty times more expensive than the others, Streams makes you provision for the expensive stage everywhere, or split it into a separate application communicating over a repartition topic.

Flink decouples operator parallelism from source parallelism. The expensive operator gets parallelism 64 while the source stays at 12. For pipelines with sharply uneven per-stage cost — heavy enrichment, CPU-bound parsing, external lookups — this is a real advantage and sometimes the deciding one.

Recovery behaviour

Streams recovers by replaying the changelog topic into a fresh RocksDB store. With num.standby.replicas configured, a warm replica takes over in seconds; without standbys, restoring tens of gigabytes of state can take tens of minutes, during which the partitions it owns are not processing. The failure is partial: only the tasks on the lost instance stall.

Flink recovers by restoring the last completed checkpoint. Classically this meant restarting the whole job — a single TaskManager loss costing the entire pipeline a few seconds to a few minutes of downtime; newer versions narrow the blast radius to the failed region. Recovery time is bounded by checkpoint restore from object storage rather than by changelog replay, which is usually more predictable at large state sizes.

Neither is better in the abstract. Ask instead: is a partial stall across one third of your keys for fifteen minutes worse than a complete stall across all keys for ninety seconds? Different businesses answer differently, and the answer should be written down before the first outage, not during it.

Sources and sinks

Kafka Streams reads from Kafka and writes to Kafka. Everything else is your own code inside the topology, or a Connect pipeline either side of it. That constraint is clarifying when your pipeline genuinely is Kafka-to-Kafka, and a wall when it is not.

Flink is a general dataflow engine with connectors for Kafka, CDC sources, JDBC, Iceberg, filesystems, and more, plus the same job running over bounded inputs for backfills. If your pipeline joins a Kafka stream against a database table and lands results in Iceberg, Flink expresses that in one job; Streams needs Connect on both ends and a strategy for the table side.

Delivery guarantees

Both offer end-to-end exactly-once for Kafka-to-Kafka work: processing.guarantee=exactly_once_v2 in Streams, checkpointing plus a two-phase-commit sink in Flink. The caveats from our earlier piece on exactly-once apply identically to both — the guarantee ends where your side effects begin, and both pay latency for it (commit interval in Streams, checkpoint interval in Flink).

One practical asymmetry: Flink's exactly-once latency floor is the checkpoint interval, which on large state is often tuned to tens of seconds. Streams commits every 100 ms by default. For low-latency exactly-once pipelines with modest state, Streams usually wins on latency.

Operational surface

This is the part teams underestimate, so be concrete about it.

Running Kafka Streams adds: RocksDB memory tuning (off-heap, not covered by your JVM heap limit), changelog topic sizing and compaction, standby replica configuration, and rebalance behaviour on deploys. Everything else — deploys, autoscaling, logs, metrics, alerts — reuses what you already have for JVM services.

Running Flink adds all of that plus a cluster: JobManager high availability, checkpoint storage and retention in object storage, savepoint discipline for upgrades, job submission in CI, slot and memory configuration, and a second set of dashboards and on-call runbooks. The Kubernetes operator and the managed services (Confluent, AWS, Ververica) remove a meaningful amount of this, and if you are going to run Flink seriously, use one of them. But the job lifecycle — savepoint, stop, redeploy, restore, and what to do when state is incompatible with the new topology — remains yours and is genuinely a new skill on the rotation.

SQL

Flink SQL is the strongest argument in Flink's favour for organisations where analysts and data engineers, not only JVM developers, need to build pipelines. A windowed aggregation as a SQL statement, deployed as a job, is accessible to a much wider group than a KStream topology in Java. Streams has no comparable surface; ksqlDB occupied that space, and its trajectory under Confluent has made it a weak bet for new work.

A decision framework

Four questions settle most cases.

1. Is the pipeline Kafka-to-Kafka? If yes, Streams is the lighter answer. If it fans into Iceberg, a warehouse, or several databases, and you want one engine for that, Flink earns its weight.

2. Who writes the pipelines? A JVM team that owns its services: Streams fits the existing model. A mixed group that needs SQL as the interface: Flink.

3. How uneven is per-stage cost, and how large is the state? Even cost and state in the low hundreds of gigabytes across instances: Streams scales fine. Sharply uneven stages, or terabytes of state needing independent operator scaling: Flink.

4. Who operates it? If there is no platform team and no appetite for a second distributed system on the rotation, choosing Flink self-managed is choosing an operational commitment, not a library. Either take a managed runtime or take Streams.

Two answers that are not on the list

Sometimes the right call is neither. A plain consumer-producer service — poll, transform, produce, commit — is the correct tool for stateless mapping, filtering, and routing. It is less code than a Streams topology, has no changelog topics, and every engineer on the team can read it. Reach for a stream processor when you need state: joins, windows, aggregations, deduplication over time. Stateless work does not need one.

And sometimes the answer is both, which is less of a failure than it sounds. Streams for the service-local topologies owned by product teams; Flink for the centrally-operated analytical and lakehouse-facing jobs. The cost is two sets of runbooks, so make it a deliberate split with a written rule about which work goes where — not an accident of whoever started first.

What we would do on day one

Pick the pipeline you already have the clearest requirements for, write down its state size, its latency objective, its sources and sinks, and its recovery expectation in plain numbers. Then build it twice if the decision is close and the team is new to both — a week on each prototype, with a deliberate TaskManager kill and a deliberate pod kill in each, tells you more than any comparison table, including this one.

The engines are both good. The question is which one your team can still operate confidently in eighteen months, after the people who chose it have moved on.