0%
(updated )8 min read🟡IntermediateApache Flink

Flink vs Spark Structured Streaming: State, Latency, and the Team You Actually Have

A production comparison of Flink and Spark Structured Streaming: how each one holds state, what their latency floors really are, and why the honest deciding factor is usually your existing batch stack.

D
Dinesh ChandraSenior Data & AI Engineer · SnowPro Certified
Illustrated overview of Flink vs Spark Structured Streaming: State, Latency, and the Team You Actually Have
  • ✓Spark Structured Streaming is micro-batch by default. Its latency floor is one trigger interval, which is seconds, not milliseconds.
  • ✓Flink is event-at-a-time with first-class keyed state and timers. That is the whole reason to take on a second engine.
  • ✓If your requirement is "under a minute", Spark wins on operations because your batch team already runs it.
  • ✓If your requirement is per-key state machines, session logic, or sub-second reaction, Spark will fight you the entire way.
  • ✓Neither engine gives you exactly-once into an external database. Your sink still has to be idempotent.
Table of contents

Most Flink-versus-Spark posts compare latency numbers and stop there. That is the least useful part of the decision. I have run both in production, and the thing that decided each project was never the benchmark — it was how much state the job had to hold per key, and whether the team already operated Spark for batch.

The short version: Spark Structured Streaming is a very good incremental batch engine wearing a streaming API. Flink is an actual stream processor. Both statements are compliments. They just point at different jobs.

flowchart LR
  src[Kafka topic] --> spark["Spark: micro-batch<br/>trigger every N seconds"]
  src --> flink["Flink: event at a time<br/>keyed state + timers"]
  spark --> sink1[Sink writes per batch]
  flink --> sink2[Sink writes continuously]

The execution model is the difference. Everything operational follows from it.

The execution model, and what it costs you

Spark Structured Streaming collects records into a micro-batch, runs a job over that batch, commits offsets, and repeats. Even with Trigger.ProcessingTime("1 second") you are running a tiny Spark job every second, with the scheduling and task setup that implies. The latency floor is your trigger interval plus job overhead. Continuous processing mode exists and I have never seen a team run it in production with a straight face.

Flink runs operators as long-lived tasks. A record arrives, flows through the operator chain, and updates state. There is no batch boundary to wait for. That is why Flink can hold sub-second end-to-end latency and why its state backend is a first-class piece of the system rather than a checkpoint file.

The practical consequence is not “Flink is faster”. It is that in Spark, every stateful operation has to be expressible in the micro-batch model. Deduplication, joins, and aggregations are supported. Arbitrary per-key logic is flatMapGroupsWithState, and that API is where Spark streaming projects go to become unmaintainable.

State is the real dividing line

Ask one question: does each key need its own state machine with its own timers?

If the answer is no — you are windowing, aggregating, deduplicating, or enriching — Spark handles it fine. Watermarks work, state is checkpointed, and you can reason about it. I covered the mechanics of watermarks and checkpoints in the Structured Streaming production guide.

If the answer is yes, Flink is built for exactly that. Keyed state with ValueState, MapState, and registered event-time timers is the core abstraction, not an escape hatch. A fraud rule that says “if three failed logins from one account within ten minutes, and no successful login since, emit an alert” is thirty lines of Flink and a genuine engineering project in Spark.

// Flink: per-key state and an event-time timer, which is the thing
// Spark makes you fight for.
public class FailedLoginRule
    extends KeyedProcessFunction<String, LoginEvent, Alert> {

  private transient ListState<Long> failures;

  @Override
  public void open(Configuration cfg) {
    failures = getRuntimeContext().getListState(
        new ListStateDescriptor<>("failures", Long.class));
  }

  @Override
  public void processElement(LoginEvent e, Context ctx, Collector<Alert> out)
      throws Exception {
    if (e.successful()) {
      failures.clear();
      return;
    }
    failures.add(e.timestamp());
    ctx.timerService().registerEventTimeTimer(e.timestamp() + 600_000L);

    long recent = 0;
    for (long t : failures.get()) {
      if (e.timestamp() - t <= 600_000L) recent++;
    }
    if (recent >= 3) out.collect(new Alert(ctx.getCurrentKey(), recent));
  }

  @Override
  public void onTimer(long ts, OnTimerContext ctx, Collector<Alert> out)
      throws Exception {
    // expire the window without a full state scan
    failures.clear();
  }
}

The Spark equivalent means holding a state object per group, managing your own timeout via GroupState.setTimeoutTimestamp, and accepting that the timeout only fires when a batch runs. It works. It is not the same tool.

Watermarks and late data behave differently enough to matter

Both engines use watermarks, and both drop late events on purpose. The difference is control.

Flink lets you set allowed lateness per window and route dropped events to a side output. You can build a “late arrivals” topic and actually see what you are losing. Spark gives you a watermark and a state timeout, and late records past the watermark are silently discarded. Nobody sets an alert on records Spark did not tell them about.

If your data has genuinely messy event time — mobile clients, IoT devices, anything with offline buffering — that side output is not a nice-to-have. I have spent more time debugging “where did those rows go” on Spark streams than on Flink ones, and the side-output ability is most of the reason. The event time and watermark post goes deeper on the semantics.

Exactly-once: the claim both engines make and neither fully keeps

Flink gives you exactly-once state via distributed snapshots, and end-to-end exactly-once to sinks that support two-phase commit — Kafka, and file sinks with atomic rename.

Spark gives you exactly-once via checkpointed offsets plus idempotent or transactional sinks, which in practice means Delta, Iceberg, or foreachBatch with your own logic.

Neither of them makes a MERGE into Postgres exactly-once. The moment the sink is an external database, you own idempotency yourself — a natural key, a MERGE, or a dedupe on a content hash. This is the same boundary I described in the Kafka exactly-once post, and it does not move because you changed engines.

Operations, which is where the decision usually lands

Here is the part vendor comparisons skip.

Spark means one engine, one skill set, one deployment story. If your team runs Databricks or EMR for batch, a streaming job is a different trigger on tooling that already exists. Autoscaling, logging, and the Spark UI are the same. On Databricks it is a job with a checkpoint location, and the operational surface is close to zero new things.

Flink means a second cluster, a second set of failure modes, and a savepoint discipline your team has to learn. Job upgrades go through savepoints, not restarts. State backend sizing (RocksDB tuning, incremental checkpoints, local disk) becomes real work once state passes a few hundred gigabytes. Managed options — Confluent, Ververica, Kinesis Data Analytics — reduce this but do not remove the concepts.

I have never regretted choosing Spark for a job that fit the micro-batch model. I have regretted choosing Spark for a job that needed per-key timers, and I have regretted introducing Flink for a job whose real requirement turned out to be five-minute freshness.

flowchart TD
  q1{Sub-second latency required?} -->|yes| flink[Flink]
  q1 -->|no| q2{Per-key state machines or timers?}
  q2 -->|yes| flink
  q2 -->|no| q3{Already running Spark for batch?}
  q3 -->|yes| spark[Spark Structured Streaming]
  q3 -->|no| q4{Is streaming even the requirement?}
  q4 -->|no| batch["Incremental batch + dbt"]
  q4 -->|yes| flink

Two of the four paths end somewhere other than a new streaming cluster.

Pitfalls

Choosing streaming when the SLA is 15 minutes. An incremental batch job on a five-minute schedule is cheaper, easier to backfill, and easier to hire for. Streaming is an operational commitment, not a performance upgrade.

Unbounded state in either engine. A stream-stream join without a watermark, or a keyed state with no TTL, grows until the job dies. Set state retention explicitly. Both engines will happily let you not do this.

Assuming Flink’s latency automatically reaches your users. If the sink is a warehouse that batches commits, or a BI tool with a 15-minute cache, your millisecond pipeline delivers 15-minute data. Measure end-to-end freshness, not operator latency.

Running Flink SQL and thinking you have avoided the operations. Flink SQL is excellent and it still produces a stateful job with checkpoints, savepoints, and a state backend to size.

Restarting a Flink job instead of taking a savepoint. You will lose state and rebuild from the retention window, which is a different dataset than the one you had.

Micro-batch tuning by shrinking the trigger. A one-second trigger on a job whose batch takes four seconds gives you a permanent backlog, not fresher data.

FAQ

Is Flink always lower latency than Spark? For the same job, yes, structurally — Flink has no batch boundary. Whether that difference reaches anyone depends on your sink and your consumers. Sub-second matters for alerting and serving; it rarely matters for a dashboard.

Can Spark do everything Flink does? Almost, with more code. flatMapGroupsWithState covers arbitrary keyed logic and Spark 3.5+ improved the API considerably. The question is whether you want to maintain that code, not whether it compiles.

What about Kafka Streams? A real option when the job is Kafka-to-Kafka and fits in a JVM service you already deploy. No cluster, no scheduler, scales by running more instances. If you do not need Flink’s operator graph or Spark’s ecosystem, it is often the least ceremony — see the Kafka Connect post for where connectors end and processing begins.

We are on Databricks. Is Flink even worth evaluating? Only for genuine event-driven state machines or sub-second SLAs. If you are windowing and aggregating, Structured Streaming on the platform you already pay for wins on total cost, including the cost of your attention.

How much state is too much for Spark? There is no clean number, but once keyed state passes low hundreds of gigabytes and you care about recovery time, Flink’s incremental RocksDB checkpointing is a materially better story.

What this means for data engineers

Decide on state and SLA, in that order, before anyone opens a benchmark. Per-key state machines and sub-second reaction are Flink problems. Windowed aggregation on a stack that already runs Spark is a Spark problem, and pretending otherwise costs you a cluster and a skill set you have to keep staffed.

Then check whether the requirement is streaming at all. A large share of “real-time” requests I have taken are satisfied by a five-minute incremental batch, and that pipeline is one you can backfill, test, and hand to someone else on a Friday.

Share this post:X / TwitterLinkedInRedditWhatsApp
D

Dinesh Chandra

Senior Data & AI Engineer · SnowPro Core & Gen AI Certified

4+ years building production data platforms on Snowflake, Databricks, AWS, and Azure. I write from hands-on experience with Medallion architectures, dbt, Airflow, PySpark, and Snowflake Cortex AI. Every tutorial on this blog reflects real pipeline decisions — not documentation rewrites.

Read more about the author →

More on Apache Flink

Search DataLane

↑↓ navigate↵ openesc close