Apache Flink: Advanced Stream Processing at Scale

Apache Flink provides advanced stream processing with sophisticated windowing and event-time handling. Learn its architecture, programming model, and use cases.

published: reading time: 26 min read author: GeekWorkBench updated: June 17, 2026
Quick Summary

Apache Flink processes bounded and unbounded streams with event-time watermarks, stateful operators, and checkpoint-based recovery. The guide walks through its DataStream and SQL APIs, connectors, state backends, and windowing, then examines production problems such as checkpoint overload, idle partitions, key skew, and backpressure. Capacity estimates and monitoring signals help teams test recovery and size workloads, while the comparisons with Kafka Streams and Spark help decide when Flink’s cluster operations are justified.

Apache Flink: Advanced Stream Processing at Scale

Introduction

Kafka Streams handles filter, map, join, and aggregate. For simple pipelines, it is sufficient. But when you need sophisticated windowing, event-time processing with out-of-order data, or complex stateful computations across streams, you need something more powerful.

Apache Flink is a distributed processing engine for stateful computations over unbounded and bounded data streams. It runs on a cluster (YARN, Kubernetes, or standalone) and provides sophisticated windowing, event-time processing, and exactly-once guarantees across arbitrary data sources.

This guide covers Flink’s distributed architecture and APIs, then explains event-time watermarks, windows, state, and fault tolerance. It also examines connectors, deployment trade-offs, checkpointing, state growth, and recovery failures to help assess whether Flink fits a stream-processing workload.

Flink follows the master-worker pattern. The JobManager is the master. It schedules tasks, coordinates checkpoints, and manages the distributed coordination. TaskManagers are the workers. They execute tasks and maintain state.

flowchart LR
    subgraph JobManager[JobManager]
        JM[JobManager]
        CheckpointCoordinator[Checkpoint Coordinator]
    end
    subgraph TaskManagers[TaskManagers]
        TM1[TaskManager 1]
        TM2[TaskManager 2]
        TM3[TaskManager 3]
    end
    CheckpointCoordinator -->|checkpoint barrier| TM1
    CheckpointCoordinator -->|checkpoint barrier| TM2
    CheckpointCoordinator -->|checkpoint barrier| TM3

Flink programs run as parallel stream processing jobs. Each operator runs as multiple parallel instances (subtasks), each processing a subset of the stream partitions.

DataStream API: The Foundation

Flink’s DataStream API is the low-level API for stream processing. It provides fine-grained control over time, state, and watermarks.

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.functions.ProcessFunction;
import org.apache.flink.util.Collector;

public class EventProcessor {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env =
            StreamExecutionEnvironment.getExecutionEnvironment();

        env.enableCheckpointing(10000);  // Checkpoint every 10 seconds
        env.getCheckpointConfig().setCheckpointStorage("s3://flink-checkpoints/");

        DataStream<Event> events = env
            .fromSource(
                KafkaSource.<Event>builder()
                    .setBootstrapServers("kafka:9092")
                    .setTopics("events")
                    .setGroupId("flink-consumer")
                    .build(),
                WatermarkStrategy
                    .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(30))
                    .withTimestampAssigner((event, timestamp) -> event.getEventTime()),
                "Kafka Source"
            );

        DataStream<Alert> alerts = events
            .keyBy(Event::getUserId)
            .window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
            .process(new FraudDetectionProcessFunction());

        alerts
            .sinkTo(SinkWriter.newBuilder()
                .setKafkaProducer(new FlinkKafkaProducer<>(
                    "alerts",
                    new AlertSerializer(),
                    KafkaSinkProperties.create()
                ))
                .build());

        env.execute("Fraud Detection Pipeline");
    }
}

Event Time and Watermarks

The hardest problem in stream processing is handling data that arrives late or out of order. Network partitions, upstream retries, and scheduled batch jobs all cause events to arrive after they should have.

Flink solves this with event time and watermarks.

Event time is the time embedded in the event itself (when the event actually happened). Processing time is when the system processes the event. Using event time lets you reason about the data correctly regardless of when it arrived.

Watermarks are progress markers in the event time stream. A watermark with timestamp T tells Flink: “All events with event time less than T have arrived.” When a watermark arrives, Flink advances its view of time and closes windows that should be complete.

WatermarkStrategy
    .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(30))  // Allow 30 seconds late
    .withTimestampAssigner((event, timestamp) -> event.getEventTime())
    .withIdleness(Duration.ofMinutes(1));  // Handle idle partitions

The watermark strategy defines how out-of-order you expect your data to be. If you set a 30-second watermark, Flink waits up to 30 seconds for late events before closing a window.

Late data handling

When events arrive after the watermark, they are considered late. Flink has three options for handling late data:

// Option 1: Side output late events
OutputTag<Event> lateEventsTag = new OutputTag<Event>("late-events"){};

DataStream<Alert> alerts = events
    .keyBy(Event::getUserId)
    .window(SlidingEventTimeWindows.of(Time.minutes(5)))
    .sideOutputLateData(lateEventsTag)
    .process(new AggregationProcessFunction());

DataStream<Event> lateEvents = alerts
    .getSideOutput(lateEventsTag);

// Option 2: Allow late events to update window results
DataStream<Alert> alerts = events
    .keyBy(Event::getUserId)
    .window(SlidingEventTimeWindows.of(Time.minutes(5)))
    .allowedLateness(Duration.ofMinutes(2))  // Allow 2 minutes late
    .process(new AggregationProcessFunction());

Windowing

Flink supports several window types:

Tumbling windows: Fixed-size, non-overlapping windows. Every 5 minutes. Events belong to exactly one window.

Sliding windows: Fixed-size with overlap. Every 5 minutes, but windows overlap by 1 minute. Events can belong to multiple windows.

Session windows: Windows defined by gaps in activity. A session window closes when there is a gap of inactivity longer than the session timeout.

// Tumbling window: count events every 5 minutes
events
    .keyBy(Event::getUserId)
    .window(TumblingEventTimeWindows.of(Time.minutes(5)))
    .aggregate(new CountAggregator());

// Sliding window: rolling 10-minute average with 1-minute updates
events
    .keyBy(Event::getMetricName)
    .window(SlidingEventTimeWindows.of(Time.minutes(10), Time.minutes(1)))
    .aggregate(new AverageAggregator());

// Session window: aggregate until 5 minutes of inactivity
events
    .keyBy(Event::getSessionId)
    .window(EventTimeSessionWindows.withGap(Time.minutes(5)))
    .process(new SessionAggregator());

State and Fault Tolerance

Flink’s state is first-class. Every operator can maintain state, and Flink checkpoints that state to durable storage. On failure, Flink restores operator state from checkpoints and resumes processing.

public class RunningAverageFunction
    extends ProcessFunction<Event, Double> {

    private ValueState<Long> countState;
    private ValueState<Double> sumState;

    @Override
    public void open(Configuration parameters) {
        countState = getRuntimeContext()
            .getState(new ValueStateDescriptor<>("count", Long.class));
        sumState = getRuntimeContext()
            .getState(new ValueStateDescriptor<>("sum", Double.class));
    }

    @Override
    public void processEvent(Event event, Context ctx, Collector<Double> out) {
        Long currentCount = countState.value();
        Double currentSum = sumState.value();

        if (currentCount == null) {
            currentCount = 0L;
            currentSum = 0.0;
        }

        countState.update(currentCount + 1);
        sumState.update(currentSum + event.getValue());

        double average = sumState.value() / countState.value();
        out.collect(average);
    }
}

Flink’s checkpointing uses the Chandy-Lamport algorithm for distributed snapshots. Checkpoint barriers flow through the stream graph. When all inputs have received the barrier for a checkpoint, the checkpoint is complete. This provides exactly-once processing without pausing the pipeline.

Table API and SQL

Flink provides a Table API and SQL interface for stream processing. This makes complex stream analytics accessible to anyone who knows SQL.

-- Flink SQL: compute running revenue per customer
SELECT
    customer_id,
    TUMBLE_START(event_time, INTERVAL '5' MINUTE) AS window_start,
    SUM(amount) AS total_revenue,
    COUNT(*) AS transaction_count
FROM transactions
GROUP BY
    customer_id,
    TUMBLE(event_time, INTERVAL '5' MINUTE);

-- Detect anomalies: flag customers with unusual spike in activity
WITH customer_activity AS (
    SELECT
        customer_id,
        HOP_START(event_time, INTERVAL '1' MINUTE, INTERVAL '10' MINUTE) AS window_start,
        COUNT(*) AS activity_count
    FROM events
    GROUP BY
        customer_id,
        HOP(event_time, INTERVAL '1' MINUTE, INTERVAL '10' MINUTE)
),
averages AS (
    SELECT
        AVG(activity_count) AS avg_activity,
        STDDEV(activity_count) AS stddev_activity
    FROM customer_activity
)
SELECT
    ca.customer_id,
    ca.window_start,
    ca.activity_count
FROM customer_activity ca
CROSS JOIN averages a
WHERE ca.activity_count > a.avg_activity + 3 * a.stddev_activity;

Connectors

Flink connectors integrate with external systems:

Sources: Kafka, Kinesis, Pulsar, JDBC, file systems, custom sources

Sinks: Kafka, Kinesis, Pulsar, JDBC, file systems, Elasticsearch, HTTP, custom sinks

Async I/O: For integrating with external services that have high latency (REST APIs, databases), Flink provides async I/O operators that handle thousands of concurrent requests efficiently.

Security and Compliance Notes

Treat job submission and the Flink REST endpoint as privileged control-plane access. Restrict the endpoint to trusted operators and networks, require authenticated access through your platform or an authenticating proxy, and enable TLS for REST and internal Flink traffic. The REST endpoint can submit jobs and inspect or modify running jobs, so network reachability alone should not grant access. See the Apache Flink SSL setup guide for the deployment settings.

Give each source and sink connector only the permissions its job needs. Keep broker, database, and cloud credentials in the deployment platform’s secret store or mounted secret files; avoid embedding them in job code, SQL files, container images, or logs. Rotate credentials and remove access when a job is retired.

Assume operator state and checkpoints may contain sensitive event fields. Use encrypted, access-controlled checkpoint and savepoint storage, and limit access to the storage identity used by the job. Minimize or tokenize personal data before it enters long-lived state, and avoid logging raw records, credentials, or tokens. Set state TTL where the business rules allow it, then define retention and deletion for checkpoints, savepoints, logs, and source data as well. State TTL does not erase older snapshots or copies held outside Flink.

Flink is the right choice when:

  • You need sophisticated windowing (session windows, multiple window types)
  • Your data arrives out of order and you need event-time processing
  • You need exactly-once processing with non-Kafka sources or sinks
  • You need very large state (Flink scales to terabytes of state per TaskManager)
  • You need SQL-based stream analytics

Kafka Streams is simpler for straightforward filter-map-join-aggregate pipelines where all sources and sinks are Kafka topics.

Production Failure Scenarios

Flink’s distributed nature creates failure modes that single-node processing sidesteps entirely.

JobManager single point of failure

The JobManager coordinates everything — task scheduling, checkpoints, failure recovery. If it dies without HA configured, the job stops dead. No coordination happens, no recovery triggers.

Fix: run JobManager HA with standby instances. On Kubernetes this is straightforward. On YARN, use the built-in HA mode. For anything that matters, a single JobManager is not acceptable.

Under the hood, Flink’s HA mode runs multiple JobManager instances with a shared state store (typically ZooKeeper or Kubernetes ConfigMaps). When the active JobManager fails, a standby instance takes over by restoring the last checkpoint metadata and reassigning task slots to the remaining TaskManagers. Recovery time scales with state size — a 500GB checkpoint takes longer to restore than a 50GB one, so factor this into your SLA calculations.

The catch is in-flight records. When the JobManager dies, records mid-processing do not replay unless your source connector supports idempotent emission. Kafka sources handle this naturally; a file source or JDBC connector may emit duplicates on recovery. Audit your connectors before relying on exactly-once semantics across a failover.

Do this in staging: kill the JobManager process, measure time-to-recovery and duplicate record count. That is the only way to know whether your HA configuration actually works.

Checkpoint overload

Checkpoints save state to S3 or HDFS. When state hits terabytes, checkpoint files get large. If checkpoint duration exceeds the checkpoint interval, the next checkpoint starts before the previous one finishes. The backlog grows, state falls behind, and you enter a death spiral.

Fix: target checkpoint duration at less than 60% of your checkpoint interval. If checkpoints are consistently taking too long, either shrink your state or widen the interval.

Usually the culprit is storage bandwidth saturation. A 1TB state written to S3 at 10 Gbps takes roughly 13 minutes — if your checkpoint interval is 10 minutes, you are already behind before the first checkpoint finishes. S3 listing operations also add latency: a checkpoint with 50,000 files incurs 50,000 HEAD requests before the write begins. Prefix-based partitioning in your checkpoint path keeps file counts per directory manageable, and checkpoint expiration policies clean up old checkpoints automatically.

Incremental checkpoints help significantly. Instead of writing the full state every time, Flink writes only the diffs since the last checkpoint. For event logs and time-series, incremental checkpoints can cut write volume by 80-90%. The tradeoff is that recovery now replays a chain of incremental checkpoints rather than a single full snapshot, which can slow recovery for very old checkpoints.

Watch checkpoint_start_delay in particular — it measures the gap between when a checkpoint was supposed to start and when it actually started. A consistently growing delay is the earliest sign of an impending checkpoint spiral, before you see duration exceed the interval.

State backend mismatches production load

Flink has two main state backends: Heap (fast, memory-bound) and RocksDB (disk-backed, scalable). Teams pick Heap for speed and hit OOM in production. TaskManagers crash and restart in a loop, each time loading the same oversized state.

Fix: RocksDB for any state that could grow past a few gigabytes. Profile under realistic load before drawing conclusions about memory requirements.

The heap backend stores state in JVM objects on the managed heap. Every key-value pair is a Java object with object header overhead, reference pointers, and alignment padding. A ValueState with 10 million entries might consume 2-3 GB of heap per operator instance — before you account for the operator code, network buffers, or JVM metaspace. When the heap exceeds the configured memory, the TaskManager gets killed by the containerizer (Kubernetes or YARN), and the restart loads the same state back into the same heap, creating a crash loop.

RocksDB offloads state to local disk using an embedded instance per operator. Memory stays bounded because RocksDB manages its own block cache and write buffer separately from the JVM heap. The tradeoff is CPU overhead for serialization and deserialization — state is stored as bytes, not Java objects — and disk I/O for state access. For joins or windowed aggregations with small windows, profile the RocksDB read amplification: a poorly tuned config can cause 5-10 reads per state access in the worst case.

Moving from Heap to RocksDB is not frictionless. RocksDB state is incompatible with heap snapshots — you cannot just flip a switch. The cleanest path is to run a parallel job on RocksDB, validate performance at scale, then cut over traffic once the new job is stable. Flink’s state migration tool helps but it operates offline and takes time proportional to your state size.

Watermark stalls on idle partitions

A Kafka partition stops producing — upstream drained it or a rebalance is in progress. Other partitions keep going. The watermark stalls. Windows never close because one partition’s watermark never moves. Meanwhile, late events pile up.

Fix: use withIdleness() to exempt dead partitions from watermark advancement. Alert on watermark stalls per partition — a frozen watermark is a clear signal something is wrong upstream.

The watermark mechanism tracks the minimum watermark across all input channels. When one Kafka partition goes silent, its watermark stops advancing. The overall watermark — which determines when windows close — cannot advance past the slowest channel. A rebalance on a low-traffic partition can hold your entire job hostage indefinitely.

The withIdleness(Duration.ofMinutes(1)) call on the watermark strategy marks a partition as idle after 1 minute of no records. Flink then excludes that partition from the watermark minimum calculation, allowing windows to close based on the remaining active partitions. Set this duration based on your expected maximum partition downtime — too short and you risk premature window closure on legitimately slow partitions, too long and you extend watermark lag unnecessarily.

Watch for watermark staleness in your metrics before it becomes a production incident. The currentInputChannelWatermarkNames metric gives per-partition watermark values. If one partition diverges by more than your watermark lag tolerance from the others, investigate immediately. Late events accumulate in checkpoint state while the window stays open, so state grows even though no results are being produced.

Backpressure cascading through the DAG

A sink starts throttling — Elasticsearch bulk rejection, a database lock, a HTTP endpoint returning 429. Input buffers fill. This backpressures upstream operators, which backpressures their upstream, all the way to the source.

Fix: isolate sinks that can throttle in their own operator chain with independent parallelism. Monitor buffer utilization and queue lengths. Design sinks to handle backpressure explicitly, not silently.

Aspect Apache Flink Spark Streaming Kafka Streams
Processing model Native streaming Micro-batch Native streaming
Latency Sub-second ~500ms minimum Sub-second
State management Distributed RocksDB In-memory + disk spill Local RocksDB
Exactly-once End-to-end Micro-batch guarantee Kafka-only
Windowing Time, session, count, global Time (micro-batch) Time, session, sliding
Event time Native Simulated (watermarks) Supported
SQL support Native Table API + SQL Spark SQL No (use ksqlDB)
Operational complexity High Medium Low
Scaling Fine-grained, dynamic Coarse (partition-based) Partition count ceiling
Connectors Many Many Kafka only
Best for Complex event processing, large state Batch migration, simpler streaming Kafka-native microservices

Flink wins for complex event patterns and large-scale stateful processing. Spark Streaming wins for teams migrating from batch Spark. Kafka Streams wins when you want to avoid operating a processing cluster entirely.

Capacity Estimation

State sizing

Flink state lives in RocksDB (default for large state) or on the JVM heap.

// Estimate RocksDB state size per TaskManager
// Assumptions: 50M keys, 4 TaskManagers, 200 bytes per value, 2.5x RocksDB overhead

long totalKeys = 50_000_000L;
int numTaskManagers = 4;
long keysPerTM = totalKeys / numTaskManagers;  // 12.5M keys

long bytesPerValue = 200L;
double rocksDBMultiplier = 2.5;  // keys + indexes + bloom filters + tombstones

long stateBytesPerTM = keysPerTM * bytesPerValue * rocksDBMultiplier;
// ≈ 6.25GB per TaskManager

// Add 30% for incremental checkpoint overhead during recovery
long totalStatePerTM = stateBytesPerTM * 1.3;  // ≈ 8.1GB

For heap-based state, account for JVM heap after operator memory, network buffers, and GC headroom. A 16GB TaskManager with 4GB for state, 4GB for network, and 4GB for operators leaves 4GB for heap — tight.

Checkpoint storage bandwidth

Checkpoints write full state periodically and incremental diffs between. Storage bandwidth matters for checkpoint duration.

Checkpoint interval State size Write rate
60 seconds 100GB ~1.7 GB/sec
60 seconds 1TB ~17 GB/sec
5 minutes 100GB ~0.33 GB/sec

For S3, make sure your network link handles the write rate. Recovery reads are sequential and bulk — same bandwidth, same constraint.

Throughput

Per core, assuming CPU is the bottleneck:

Operation Records/sec per core
Stateless filter/map 100–200K
Simple window aggregate 50–100K
Stateful join 20–50K
Complex CEP pattern 5–20K

A 4-core TaskManager doing a stateful join handles 80–200K records/sec. Profile with production data shapes before committing to a cluster size.

Observability Checklist

Flink exposes metrics via REST API, Prometheus, or Grafana.

Checkpoints: last-checkpoint-duration, last-checkpoint-size, number-of-inprogress-checkpoints. Checkpoint duration trending toward the checkpoint interval means the next checkpoint will start late.

State: state-per-task-gauge per operator. State growing without a corresponding business reason means a memory leak or missing TTL.

Backpressure: task_busy_time and output_throughput together. High busy time with low throughput means a bottleneck — either CPU-bound or a deadlock.

Watermarks: current-event-time-watermark per operator. Stalled watermark while others advance means an idle partition is blocking window closure.

# Checkpoint health
curl -s http://flink-jobmanager:8081/jobs/<job_id>/metrics?get=numFailedCheckpoints,lastCheckpointDuration,lastCheckpointSize

# Backpressure — high ratio means TaskManager is backed up
curl -s http://flink-taskmanager:8082/taskmanagers/<tm_id>/metrics?get=busyTimeMs,busyTimeMsPerSecond

Alert on: any failed checkpoint, checkpoint duration above 80% of interval, watermark stall longer than 5 minutes, TaskManager heap above 80% sustained.

Common Pitfalls / Anti-Patterns

Forgetting to set state TTL

State lives forever unless you configure a TTL. A session window that never closes because the session gap is never reached holds state indefinitely. Months later you discover tens of gigabytes of stale state on every TaskManager.

Always set TTL on state descriptors. The state.clear() pattern is not enough — it only clears on the happy path.

Every state descriptor accepts a StateTtlConfig. Here is the API in practice:

import org.apache.flink.api.common.state.StateTtlConfig;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;

// Session windows: shorter TTL matching your gap duration
StateTtlConfig sessionTtl = StateTtlConfig.newBuilder(Time.minutes(10))
    .setUpdateType(StateTtlConfig.UpdateType.OnReadAndWrite)
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
    .cleanupInRocksdbCompactFilter(1024)  // Compact filter evicts expired on compaction
    .build();

ValueStateDescriptor<SessionState> sessionDesc =
    new ValueStateDescriptor<>("session", SessionState.class);
sessionDesc.enableTimeToLive(sessionTtl);

// Time windows: TTL covers the window length plus allowed lateness
StateTtlConfig windowTtl = StateTtlConfig.newBuilder(Time.minutes(15))
    .setUpdateType(StateTtlConfig.UpdateType.OnReadAndWrite)
    .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired)
    .cleanupInRocksdbCompactFilter(1024)
    .build();

// MapState and ListState need TTL just like ValueState
MapStateDescriptor<String, AggregatedData> mapDesc =
    new MapStateDescriptor<>("windowState", String.class, AggregatedData.class);
mapDesc.enableTimeToLive(windowTtl);

ListStateDescriptor<Event> listDesc =
    new ListStateDescriptor<>("events", Event.class);
listDesc.enableTimeToLive(windowTtl);

StateTtlConfig.UpdateType.OnReadAndWrite updates the TTL on every read and write. OnCreateAndWrite only refreshes on writes, which is cheaper but means unused keys never expire. NeverReturnExpired prevents stale data from being returned even if compaction has not yet purged it.

RocksDB compaction is where most teams get caught. Flink has a RocksDB-specific TTL expiry mechanism called the compact filter. It runs inside RocksDB compaction threads and checks TTL on each key. The second argument (1024 above) controls how many keys the compaction filter inspects per second per core. Set it too low and expired keys linger past their TTL. Set it too high and compaction slows. Profile this under realistic write rates.

For reducing RocksDB compaction overhead, also consider cleanupInBackground combined with incremental checkpoints. The incremental checkpoint diff already reduces the working set — compact filter only scans the changed files.

The statePerTask metric per operator is the leading indicator to watch. A ValueState holding 50 bytes per key looks harmless at 1000 keys. At 50 million keys it is 25 GB per TaskManager and climbing. Alert when statePerTask grows faster than your event rate warrants.

For reducing RocksDB compaction overhead, also consider cleanupInRocksdbCompactFilter with a throughput parameter calibrated to your compaction budget. The default is not always safe at scale.

Using processing time for correctness-sensitive workloads

Processing time is easier. It is also wrong the moment there is any lag. A 30-second network hiccup shifts your window results. Always use event time when correctness matters; processing time only belongs in dashboards where approximate is fine.

The failure modes are concrete. A billing pipeline using processing time counts events that arrived before the window closed rather than events that occurred before the window closed. After a Kafka rebalance that pauses one partition for 2 minutes, your billing window shifts 2 minutes of charges into the next window. Customers get invoiced for the wrong period. A fraud detection job using processing time sees the same problem: a 90-second GC pause on the TaskManager pushes events from a fraudulent transaction into the post-detection window, where the model is not watching. An inventory aggregator using processing time counts what arrived during the window rather than what shipped during the window, so a shipping delay looks like a stockout.

Configuring event time requires a TimestampAssigner. The extractor function pulls the timestamp from the event payload:

WatermarkStrategy<Event> eventTimeStrategy =
    WatermarkStrategy
        .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(30))
        .withTimestampAssigner((event, timestamp) -> {
            // event.getEventTime() returns epoch millis from the event itself
            return event.getEventTime();
        })
        .withIdleness(Duration.ofMinutes(1));

DataStream<Event> events = env
    .fromSource(kafkaSource, eventTimeStrategy, "Kafka Source");

To switch from processing time to event time on an existing pipeline, change the window assignment from TumblingProcessingTimeWindows to TumblingEventTimeWindows and add the watermark strategy at the source. The window API is the same. The TimestampAssigner does the work.

Verify the timestamp extractor in tests by emitting events with known timestamps and checking that watermarks advance correctly:

@Test
public void testTimestampExtractor() throws Exception {
    // Emit event with event time = 1000, watermark strategy allows 5 seconds late
    Event event = new Event(1000L, "user1", 42.0);
    WatermarkStrategy<Event> strategy = WatermarkStrategy
        .<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
        .withTimestampAssigner((e, ts) -> e.getEventTime());

    // Collect watermarks after the event
    WatermarkStrategy<Event>.WithTimestampAssigner<Event> assigner =
        strategy.createWatermarkStrategy();
    Watermark watermark = assigner.createWatermark(event, -1L);

    // Watermark should be event time minus max out-of-orderness
    assertEquals(995L, watermark.getTimestamp());
}

If the watermark strategy is not set, Flink defaults to processing time. It does not warn you. The pipeline runs, results look plausible, and the problem surfaces months later when someone notices billing discrepancies or a fraud miss and traces it back to event ordering.

Ignoring key distribution skew

State is partitioned by key. If a few keys dominate your data (a popular user, a common category), those partitions accumulate far more state than others. The overloaded TaskManager OOMs while neighbours sit half-empty.

Profile your key distribution before going to production. Use composite keys or add a hash prefix to redistribute skewed key spaces.

The metrics that expose skew are task_busy_time per operator and statePerTask per task. If one task instance consistently shows statePerTask three times the median across all instances of that operator, you have skew. A 10 GB median with one partition at 38 GB is not a rounding error. The same applies to task_busy_time — if one instance is at 95% busy while others sit at 40%, the problem is upstream, not the operator itself.

The OOM from skew is structurally different from a general OOM. A TaskManager that runs out of heap because overall state is too large for the backend dies uniformly across all operators on that host. A TaskManager that dies because one operator has a hot key shows heap exhaustion in the state backend thread, and you see StateBackend errors in the logs before the OOM, not after. If your TaskManager is dying with RocksDB errors in the log and the heap chart shows a steep cliff rather than a slow climb, look for a single dominant key.

Flink’s key grouping distributes keys across task instances. The KeyGroup concept is the granularity at which Flink redistributes keys. When you set parallelism to 4, Flink creates 4 key groups and assigns each key to a group based on a hash. You cannot manually pin a key to a group, but you can influence the distribution:

// Instead of keying by userId directly
events.keyBy(Event::getUserId);

// Add a hash prefix to spread dominant keys across groups
events.keyBy(event -> {
    String key = event.getUserId();
    // If the key space is skewed, prepend a hash bucket
    int bucket = (key.hashCode() & Integer.MAX_VALUE) % 8;
    return bucket + "_" + key;
});

This spreads the most active users across 8 buckets before the native hash partitioner sees them. The tradeoff is that related events for the same user now go to different partitions if the window is stateful — so only use this when the skew is severe and you accept the split-key consequence.

For composite keys, Flink’s Tuple keying or a custom key class handles multiple fields naturally:

// userId + sessionId composite — both fields contribute to the partition hash
events.keyBy(event -> Keyed.of(event.getUserId(), event.getSessionId()))

The Keyed type uses the hash of both fields combined, so a popular user with many session IDs distributes across partitions proportional to their session count rather than being a single hot key. Profile the key distribution on a representative data sample before shipping. Flink does not surface a histogram of key cardinality in its built-in metrics — you need to emit the distribution yourself from a ProcessFunction or use the RocksDB state backend’s built-in statistics.

Checkpoint interval set by habit

A 1-second checkpoint interval on a 100GB state is 100GB written to storage every second. The pipeline spends more time checkpointing than processing. Size your checkpoint interval based on your recovery point objective. If losing a minute of work is acceptable, checkpoint every 60 seconds.

The governing formula is:

checkpoint_interval = min(RTO_desired, state_size / write_bandwidth)

RTO_desired is the maximum acceptable recovery time. state_size / write_bandwidth is the minimum time a checkpoint takes to finish writing. Your interval must exceed both.

At 1 GB/s write bandwidth to S3 or HDFS, a 100 GB state needs 100 seconds before the write completes. Set an interval shorter than 100 seconds and the next checkpoint queues before the current one finishes. The numbers scale predictably:

State size Write bandwidth Minimum checkpoint duration Safe interval at 60% utilization
10 GB 1 GB/s 10 seconds 17 seconds
100 GB 1 GB/s 100 seconds 167 seconds (~3 min)
1 TB 1 GB/s ~17 minutes ~28 minutes
100 GB 5 GB/s 20 seconds 33 seconds
1 TB 5 GB/s ~3.3 minutes ~5.5 minutes

S3 listing latency is a separate constraint. A checkpoint that produces 50,000 files fires 50,000 HEAD requests before writing starts. Prefix-based partitioning of your checkpoint path keeps per-directory file counts sane.

Two settings cover the most common failure modes. minPauseBetweenCheckpoints limits checkpoint frequency:

env.getCheckpointConfig().setMinPauseBetweenCheckpoints(30_000L);
// Wait 30 seconds between the end of one checkpoint and the start of the next

Without it, a fast storage system triggers back-to-back checkpoints and the pipeline stalls. checkpointTimeout makes the job fail loudly if a checkpoint exceeds your window:

env.getCheckpointConfig().setCheckpointTimeout(120_000L);
// Fail the checkpoint if it takes longer than 2 minutes

Set checkpointTimeout shorter than your checkpoint interval. If a checkpoint times out but the interval is longer, the job keeps processing with stale state until the next checkpoint also fails. A timeout below the interval surfaces the problem immediately instead of letting state drift.

Quick Recap

  • Flink runs as a cluster — size, monitor, and manage it as infrastructure.
  • Use RocksDB for any state that could grow past a few GB.
  • Set a checkpoint interval based on your recovery point objective, not habit.
  • JobManager HA is not optional for any production job.
  • Watch checkpoint duration, watermark progress, and heap usage as primary signals.
  • Set state TTL on every stateful operator.

Interview Questions

1. How do event time and processing time differ in a Flink job?

Expected answer points:

  • Event time comes from when an event occurred; processing time comes from when Flink handled it.
  • Event time with watermarks keeps window results consistent when events arrive late or out of order.
  • Processing time can be useful for approximate dashboards, but it can shift results when a pipeline falls behind.
2. What does a Flink checkpoint capture, and how does it support recovery?

Expected answer points:

  • Checkpoint barriers coordinate a consistent snapshot of operator state across the job.
  • Flink stores that state in durable checkpoint storage so the job can restore it after a failure.
  • Exactly-once behavior also depends on source and sink integrations that can participate in checkpointing.
3. How would you keep keyed state from growing without bound?

Expected answer points:

  • Set a state TTL that matches the business lifetime of the data.
  • Choose an appropriate state backend and watch per-task state size and checkpoint duration.
  • Check key distribution for hot keys, since one overloaded partition can exhaust a TaskManager even when the cluster has spare capacity.
4. What happens when a watermark passes the end of an event-time window?

Expected answer points:

  • Flink can close the window and emit its result once the watermark indicates that earlier event times are complete.
  • Events arriving afterward are late; allowed lateness can update results, while a side output can capture events beyond that allowance.
  • The watermark delay is a tradeoff between waiting for out-of-order data and producing results promptly.

Further Reading

  • Apache Kafka — understand the broker, topics, and partitions that commonly feed Flink jobs.
  • Kafka Streams — compare an embedded Kafka-native stream-processing library.
  • Apache Beam — compare a portable programming model that can run on different processing engines.
  • Apache Flink documentation — consult the official guides for APIs, deployment, and operations.

Conclusion

Choose Flink when event-time semantics, large keyed state, or complex windows justify operating a processing cluster. Kafka Streams often fits simpler Kafka-only pipelines, while Beam can help when runner portability matters. Compare state size, latency, connector and sink guarantees, and the team’s operational capacity before choosing.

Before committing, test checkpoint recovery, late-event handling, and backpressure with production-shaped data. If the team cannot monitor and restore stateful jobs, a simpler processor may be safer.

Category

Related Posts

Kafka Streams: Real-Time Stream Processing Applications

Kafka Streams is a client library for real-time stream processing. Learn stream primitives, state stores, exactly-once processing, and scaling.

#data-engineering #kafka-streams #stream-processing

Apache Spark Streaming: Micro-Batch Processing

Spark Streaming uses micro-batches for real-time processing. Learn about DStreams, Structured Streaming, watermarking, and exactly-once semantics.

#data-engineering #apache-spark #spark-streaming

Change Data Capture: Real-Time Database Tracking with CDC

CDC tracks database changes and streams them to downstream systems. Learn how Debezium, log-based CDC, and trigger-based approaches work.

#data-engineering #cdc #database-replication