SysTutorials

Choosing Stream Processing Tools Based on Latency Needs

· updated April 11, 2026

The fundamental lesson from years of big data work is straightforward: solve problems with tools designed for those problems. Organizations routinely force large-scale data through mismatched infrastructure — pushing streams through micro-batching systems, analyzing graphs as tables, handling real-time requirements with batch windows. Each mismatch adds latency, complexity, and operational burden.

Stream processing deserves particular attention here. If your data arrives continuously, your processing engine should be built for continuous flows, not bolted onto batch infrastructure as an afterthought.

The Latency Problem with Micro-Batching

Micro-batching sounds reasonable in theory: collect stream data into small windows, process the batches, repeat. But every batch carries irreducible overhead — collection time, scheduling, serialization, state management. This cost remains roughly constant regardless of window size.

Shrink the window to reduce latency, and overhead dominates. You pay a fixed penalty repeatedly rather than processing data as it arrives. This becomes painful immediately in production.

Consider a practical case: a financial firm needs live aggregations over five-minute windows — current totals, anomalies, alerts. A micro-batch system still introduces a latency gap between data arrival and result availability. The workarounds are expensive: caching layers, dual systems, manual reconciliation, complex monitoring to detect staleness.

The core question cuts through everything: if data is generated continuously, why shouldn’t it be processed continuously?

True Stream Processing Architecture

Purpose-built stream processors (Apache Flink, Kafka Streams, Spark Structured Streaming in continuous mode, or Pulsar Functions) treat streams as the fundamental abstraction, not batches. Four concepts form the building blocks:

With these primitives, you implement complex stateful analytics with millisecond to sub-second latency. Join multiple streams. Maintain windowed aggregations. Detect temporal patterns. Handle late arrivals and machine failures gracefully.

A Flink job processing Kafka topics can maintain state larger than memory via RocksDB, checkpoint state consistently, and recover from failures without losing or duplicating events. Kafka Streams offers a lighter-weight option for JVM environments where you process data where it already lives. Pulsar Functions provides similar capabilities on Pulsar infrastructure.

When Micro-Batching Still Makes Sense

This isn’t an argument that micro-batching has no place. If latency tolerance is measured in minutes, a well-tuned Spark Streaming job (micro-batch mode) may be simpler operationally and cheaper than maintaining a separate stream processor. The decision hinges on matching latency requirements to tool capabilities.

Consider these scenarios:

Practical Considerations for Stream Processing

If you decide true streaming is necessary, pay attention to these areas.

Schema Management

Use Confluent Schema Registry, Pulsar Schema Registry, or similar to version and validate message schemas across producers and consumers. This prevents silent data corruption from schema mismatches. Store schemas centrally and enforce compatibility rules (backward, forward, or full compatibility depending on your deployment strategy).

State Backends and Configuration

Flink and similar frameworks offer state options: RocksDB for larger-than-memory state with checkpointing for fault tolerance, or in-memory for speed with smaller state sets. RocksDB trades some throughput for scalability — state can grow to terabytes. Configure checkpoint intervals carefully; too frequent and overhead dominates, too infrequent and recovery takes longer. Test with your actual state sizes and event rates.

Exactly-Once Semantics

Ensure your framework and message broker support exactly-once delivery. This prevents both data loss and duplication during failures. Flink provides exactly-once with Kafka and Pulsar. Kafka Streams supports exactly-once with proper configuration. Verify end-to-end exactly-once semantics includes your sink system — idempotent writes to databases or deduplication logic downstream.

Monitoring and Alerting

Stream processing introduces failure modes batch systems don’t have. Monitor:

Set alerts on lag crossing thresholds. Lag that grows unbounded will eventually cause availability issues.

Local Development and Testing

Test against real message shapes and volumes before production. Use testcontainers for Kafka or Pulsar in integration tests. Generate realistic data volumes and latency patterns. Test failure scenarios: brokers failing, network partitions, consumer lag spike from slow processing. Test state behavior: state size with peak traffic, state recovery after restart, handling of backpressure.

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(5000); // checkpoint every 5 seconds
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

FlinkKafkaConsumer<String> kafkaSource = new FlinkKafkaConsumer<>(
    "input-topic",
    new SimpleStringSchema(),
    properties);

DataStream<String> stream = env.addSource(kafkaSource);

stream
    .map(value -> new Event(value))
    .assignTimestampsAndWatermarks(
        WatermarkStrategy
            .forBoundedOutOfOrderness(Duration.ofSeconds(30))
            .withTimestampAssigner((event, timestamp) -> event.getEventTime()))
    .keyBy(Event::getUserId)
    .window(TumblingEventTimeWindows.of(Time.minutes(5)))
    .grace(Time.minutes(2))
    .aggregate(new CountAggregate(), new WindowFunction<...>(...))
    .addSink(new FlinkKafkaProducer<>(
        "output-topic",
        new SimpleStringSchema(),
        sinkProperties));

env.execute("Stream Processing Job");

Example: Kafka Streams Topology

StreamsBuilder builder = new StreamsBuilder();

KStream<String, String> input = builder.stream("input-topic");

input
    .mapValues(value -> new Event(value))
    .groupByKey()
    .windowedBy(TimeWindows.of(Duration.ofMinutes(5)).grace(Duration.ofMinutes(2)))
    .aggregate(
        () -> 0L,
        (k, v, agg) -> agg + 1,
        Materialized.as("event-count-store"))
    .toStream()
    .to("output-topic", Produced.with(
        Serdes.String(),
        Serdes.Long()));

Topology topology = builder.build();
KafkaStreams streams = new KafkaStreams(topology, streamsProperties);
streams.start();
Runtime.getRuntime().addShutdownHook(new Thread(streams::close));

Handling Late Data

Real streams don’t deliver events in perfect order. Events arrive late due to network delays, buffering, or clock skew. Define a grace period — how long after window close to accept late events. Flink and Kafka Streams both support grace periods:

.window(TumblingEventTimeWindows.of(Time.minutes(5)))
.grace(Time.minutes(2)) // accept events up to 2 minutes late
.aggregate(...)

Late events beyond the grace period can be sidecar-output to a separate topic for analysis or manual processing. Use watermarks to signal when windows should close. Watermarks represent the timestamp at which all events earlier than that timestamp have arrived:

DataStream<Event> stream = kafkaSource
    .assignTimestampsAndWatermarks(
        WatermarkStrategy
            .forBoundedOutOfOrderness(Duration.ofSeconds(30))
            .withTimestampAssigner((event, timestamp) -> event.getEventTime()));

Choosing Your Framework

Apache Flink: Most mature for complex stateful streaming, exactly-once semantics, and large-scale deployments. Strong state management, flexible windowing, and extensive ecosystem. Higher operational complexity. Good for organizations that can justify a dedicated streaming platform.

Kafka Streams: Simpler operational story if you’re already using Kafka. Lightweight, no separate cluster needed. Runs as a library in your application. Good for straightforward transformations, windowing, and joins. Limited compared to Flink for very complex state management.

Spark Structured Streaming: Unifies batch and stream via DataFrames. Good if you have existing Spark infrastructure and want a unified engine. Continuous mode provides lower latency than micro-batch but adds complexity.

Pulsar Functions: Lightweight compute directly on Pulsar infrastructure. Good for simple transformations and sinks. State management is simpler than Flink but more limited in capability.

The Real Tradeoff

Stream processing trades operational complexity for latency and result freshness. A micro-batch system may be operationally simpler if your latency requirements allow it. But trying to force sub-second latency out of a batch engine wastes engineering effort and operations budget.

Understand your problem’s actual requirements — latency SLA, throughput, state size, failure tolerance — then choose the system built to solve that problem efficiently. The tool should match the problem, not the reverse.

← All posts