Skip to main content

Event Time, Watermarks, and Late Events

Level 9: Streaming & Change Data Capturehard28 minevent time vs processing timewatermarksallowed latenesslate-event policyconditional aggregationCASE

Separate event time from processing time, apply a watermark to decide what counts as late, and measure what stragglers do to an already-emitted window.

Two clocks, and why they drift apart

Every event carries two timestamps. Event time is when the thing happened, stamped by whatever produced it. Processing time is when your pipeline saw it. In the previous lesson you bucketed on event_time_ms and ignored processing_time_ms entirely; this lesson is about the gap between them.

The gap is never zero and it is rarely small. A phone goes through a tunnel and buffers 20 minutes of taps. A retry queue drains after an outage. A partition rebalances and a consumer pauses. A client's clock is simply wrong. Every one of those makes an event arrive long after it happened, and the difference is the event's lateness:

processing_time_ms - event_time_ms

Windowing on processing time makes lateness invisible and the numbers wrong: a tunnel full of 17:05 taps lands at 17:25 and inflates the 17:25 window while the 17:05 window under-reports forever. Windowing on event time puts every event in the bucket where it belongs, which is correct but raises a new question with no free answer: when is a window done?

A watermark is a promise about completeness

A watermark is the engine's assertion that all events with an event time at or before time t have arrived. It advances behind the largest event time observed, trailing it by a bounded out-of-orderness you choose. Flink names that setting forBoundedOutOfOrderness, and its SQL form is the - INTERVAL '10' MINUTE inside a WATERMARK clause. With a 10-minute watermark, an event whose lateness exceeds 10 minutes arrives after the engine already declared its window complete, so the engine calls it late.

Do not confuse that trailing interval with allowed lateness, which is a different knob covered below. Bounded out-of-orderness decides when a window is declared complete; allowed lateness decides how long its state survives afterwards.

That single number is the completeness-versus-latency knob, and it has no correct setting. Set it to 1 minute and dashboards update fast but drop stragglers. Set it to an hour and almost nothing is missed but every window emits an hour behind. Interviewers ask what you would pick precisely because there is no right answer, only a tradeoff you can defend.

Table
The watermark is a line sweeping through processing time. Lateness is measured against event time, so the verdict depends on the gap, not on when the event happened.
event_idevent timeprocessing timelateness10-minute watermark
800317:02:0017:02:066 son time
800417:03:1017:11:108 minon time
801617:12:2017:22:2010 min exactlyon time, the boundary
800817:05:5017:17:5012 minLATE
801317:09:3017:34:3025 minLATE
The watermark is a line sweeping through processing time. Lateness is measured against event time, so the verdict depends on the gap, not on when the event happened.

Note the boundary row. The watermark asserts "everything at or before t has arrived", so an event exactly at the allowed lateness is still inside the promise. Late means strictly greater. Writing >= instead of > misclassifies exactly the events that sit on the line, which is exactly where a grader looks.

One thing to notice before you write the queries: they test processing_time_ms - event_time_ms > 600000 rather than comparing an event time against a moving watermark line. That substitution is only valid because the on-time events in this seed arrive within seconds, so the largest event time observed tracks the wall clock and the watermark sits a flat 10 minutes behind both. On a real stream the watermark lags the maximum event time rather than the clock, and the two come apart the moment a source goes idle and stops advancing it.

Where late events go

This is asked openly and word for word, and there are four canonical answers. Naming all four with a tradeoff each is the strong response.

  1. Drop them. Simplest, cheapest, and the right call when the metric tolerates a small undercount. It is also silent, so nobody notices the loss.
  2. Allowed lateness updates. Keep the window's state alive past its emission and re-emit a corrected result when a straggler shows up. This is the second knob: allowed lateness is the grace period that begins AFTER the watermark has already passed the window, during which the window's state is retained so a straggler can trigger a restatement instead of being dropped. Correct, but every downstream consumer must accept a restatement of a number it already read.
  3. Side-output to a dead-letter queue. Route late events to a separate stream or table with the record, the reason, and a timestamp, then alert on the volume. Nothing is lost, and someone gets to decide what to do about it.
  4. Append-only storage plus a windowed reprocess. Land every event raw, then recompute a bounded rolling window (the last few days) on a schedule so late arrivals self-heal without any streaming state at all. This is the pattern most batch-leaning teams actually ship.

Flink, in two words a junior needs

Awareness level, not implementation. Checkpointing is Flink's periodic consistent snapshot of operator state: barriers flow through the stream alongside the records, and on failure the job restarts from the last completed checkpoint. That mechanism is what makes its exactly-once state claim true, and it is the reason a Flink job's recovery story is "resume from a snapshot" rather than "reprocess from the beginning". Backpressure is a slow operator forcing the stages upstream of it to throttle so buffers do not blow up; from outside the job it looks like consumer lag climbing steadily with no error in the log. Say those two words correctly and stop there. Tuning either one is senior scope. On AWS, stateful Flink jobs run on Amazon Managed Service for Apache Flink, which was renamed from Kinesis Data Analytics in 2023; the old name still circulates in prep material and should not be the one you say out loud.

Common mistake: subtracting in the wrong direction and getting negative lateness, then filtering it away. Lateness is processing_time_ms - event_time_ms, arrival minus occurrence, and it is positive by construction unless a client clock is ahead of the platform's, which is itself a real finding worth reporting rather than hiding.

Interview nuance: event time versus processing time is the single most transferable streaming concept, and the way to prove you own it is with the incident story rather than the definition. "Yesterday's number changed overnight" is almost always a window that was emitted before its late events arrived, and the fix is stated as a policy choice from the four options above, not as a bug.

On a real platform this differs. Flink declares a watermark strategy on the source (WATERMARK FOR event_time AS event_time - INTERVAL '10' MINUTE) and tracks it per partition, taking the minimum across partitions so one idle partition can stall the whole job. Spark Structured Streaming spells the same idea withWatermark("event_time", "10 minutes") and uses it to decide when state can be dropped. Here the watermark is a constant in a predicate, which makes the arithmetic visible; on a platform it is a moving line the engine maintains for you, and the thing you actually configure is the out-of-orderness budget, with allowed lateness configured separately on the window.

Sample data for this example
CREATE TABLE stream_events (
  event_id           INTEGER,
  user_id            INTEGER,
  event_type         TEXT,
  event_time_ms      INTEGER,  -- when the event HAPPENED (the client's clock)
  processing_time_ms INTEGER   -- when the pipeline SAW it (the platform's clock)
);
INSERT INTO stream_events (event_id, user_id, event_type, event_time_ms, processing_time_ms) VALUES
  (8001, 201,     'page_view', 1767286820000, 1767286828000),
  (8002, 202,        'search', 1767286865000, 1767286879000),
  (8003, 203,     'page_view', 1767286920000, 1767286926000),
  (8004, 204,   'add_to_cart', 1767286990000, 1767287470000),
  (8005, 201,  'product_view', 1767287040000, 1767287049000),
  (8006, 205,     'page_view', 1767287085000, 1767287107000),
  (8007, 206,     'page_view', 1767287110000, 1767287121000),
  (8008, 202,        'search', 1767287150000, 1767287870000),
  (8009, 201,  'product_view', 1767287195000, 1767287202000),
  (8010, 205,      'checkout', 1767287240000, 1767288320000),
  (8011, 203,     'page_view', 1767287280000, 1767287296000),
  (8012, 204,        'search', 1767287325000, 1767287337000),
  (8013, 202,   'add_to_cart', 1767287370000, 1767288870000),
  (8014, 206,     'page_view', 1767287415000, 1767287434000),
  (8015, 201,        'search', 1767287480000, 1767287485000),
  (8016, 203,  'product_view', 1767287540000, 1767288140000),
  (8017, 205,     'page_view', 1767287600000, 1767287627000),
  (8018, 202,      'checkout', 1767287670000, 1767287683000),
  (8019, 204,     'page_view', 1767287705000, 1767287715000),
  (8020, 201,        'search', 1767287740000, 1767287764000),
  (8021, 206,  'product_view', 1767287785000, 1767287791000),
  (8022, 203,   'add_to_cart', 1767287820000, 1767288720000),
  (8023, 205,     'page_view', 1767287860000, 1767287891000),
  (8024, 202,        'search', 1767287900000, 1767287909000),
  (8025, 204,      'checkout', 1767287940000, 1767287957000),
  (8026, 201,     'page_view', 1767287985000, 1767287997000),
  (8027, 205,        'search', 1767288010000, 1767288017000),
  (8028, 203,     'page_view', 1767288060000, 1767288080000),
  (8029, 206,   'add_to_cart', 1767288110000, 1767289370000),
  (8030, 202,  'product_view', 1767288170000, 1767288185000),
  (8031, 204,     'page_view', 1767288220000, 1767288228000),
  (8032, 201,      'checkout', 1767288280000, 1767288309000),
  (8033, 205,     'page_view', 1767288320000, 1767288890000),
  (8034, 203,        'search', 1767288400000, 1767288411000),
  (8035, 206,     'page_view', 1767288490000, 1767288508000),
  (8036, 202,     'page_view', 1767288580000, 1767288586000);
Worked example (SQL)
-- The two clocks side by side, worst arrivals first. Everything above 600000 ms of lateness
-- is past a 10-minute watermark.
SELECT event_id,
       event_time_ms,
       processing_time_ms,
       (processing_time_ms - event_time_ms) / 1000 AS lateness_seconds,
       CASE WHEN processing_time_ms - event_time_ms > 600000 THEN 'late' ELSE 'on time' END AS verdict
FROM stream_events
ORDER BY lateness_seconds DESC
LIMIT 8;

Apply

Your turn

The task this lesson builds to.

Write a query that returns every event a 10-minute watermark would treat as late, as (event_id, event_time_ms, late_by_seconds), latest-arriving first, over stream_events(event_id, user_id, event_type, event_time_ms, processing_time_ms).

An event is late when its lateness is strictly more than 10 minutes (600000 ms); an event exactly on the boundary is still on time. Report late_by_seconds in whole seconds, and order by processing time descending so the last arrival is at the top.

2 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 3 bonus drills.

Write a query that returns every 5-minute tumbling window whose final count differs from the count it would have reported at watermark time, as (window_start_ms, on_time_count, final_count), oldest window first, over the same stream_events table.

Bucket on event_time_ms into 5-minute windows (300000 ms). on_time_count counts only the events whose lateness is at most 10 minutes (600000 ms), which is what the window held when the watermark closed it. final_count counts everything that eventually landed in the window. Keep only the windows where the two disagree, and alias the columns exactly as named.

1 hint and 1 automated check are waiting in the workspace.