Joins are essential for correlating impressions with clicks, orders, and payments in ad revenue attribution. Implementing them with low latency is a major challenge.
Apache Spark Micro-Batch Mode (MBM) reliably delivers 2–5 second end-to-end latency. However, for a growing class of operational use cases where every millisecond drives a decision, seconds are too slow. Real-Time Mode (RTM) was built for exactly this, and it now supports stream-stream joins, one of the most common stateful operations in streaming. In this blog, we'll show it in action with a classic stream-stream join example: ad click attribution pipeline.
Two streams:
You join clicks back to impressions on impression_id, within a short time window. In our demo we used 2 minutes. We generate impressions and clicks at a ~10% match rate.
The architecture looks like this:
Before this, joining two streams in Spark came down to a trade-off between latency and how much you had to build yourself:
Now RTM supports stream-stream joins natively. You get sub-second latency with the same join semantics, and none of the hand-rolled transformWithState logic.
A stream-stream join in Real-Time Mode works exactly like it does in micro-batch: the same DataFrame API, the same watermarks, the same join condition. The only difference is latency. Here's what the pipeline does.
Both streams carry an event-time column, and each gets a watermark:
impressions = impressions.withWatermark("impression_time", "5 minutes")
clicks = clicks.withWatermark("click_time", "5 minutes")
A watermark tells the engine how late data is allowed to arrive. For a stream-stream join, the engine tracks a single globalWatermark = the minimum of the two input watermarks. That globalWatermark drives two things: it handles dropping late-arriving records, and it triggers state cleanup (eviction). Because it's the minimum of the two, a stream running ahead can't prematurely drop records or evict state. The slower stream keeps the faster one from evicting the state too early.
attributed = (
impressions.alias("impressions").join(
clicks.alias("clicks"),
expr(
"""
impressions.impression_id = clicks.impression_id AND
click_time >= impression_time AND
click_time <= impression_time + interval 2 minutes
"""
),
"inner",
)
The 2-minute time bound is what lets the engine keep state bounded. Each row is held only as long as a matching row from the other side could still arrive:
The 2-minute window here is specific to this demo, choose a window that fits your use case. Keep in mind that a wider window holds rows in state longer, so state size grows accordingly.
attributed.writeStream
.queryName("adclick-attribution")
.format("kafka")
.option("kafka.bootstrap.servers",<KAFKA_BOOTSTRAP>)
.option("topic", output_topic)
.option("checkpointLocation", checkpoint_path)
.outputMode("update")
.trigger(**({"realTime": "5 minutes"} if mode == "RTM" else {"processingTime": "0 seconds"}))
.start()
The join runs in update output mode, and the trigger is the only difference between the two modes: realTime : "5 minutes" runs the query continuously in Real-Time Mode, checkpointing the stream every 5 minutes; processingTime : "0 seconds" runs it as micro-batch, starting each new batch as soon as the previous one finishes. Everything else stays the same: the join, the watermarks and the output.
Note: RTM stream-stream joins currently support inner joins in update output mode. See the Real-Time Mode reference for the full list of supported operators, sources, and sinks.
To make this reproducible, we built a companion repo that generates the data, replays it into Kafka at a controlled rate, and runs the join in either mode, so you can measure the latency difference yourself. We ran it under sustained load for 1 hour at a ~10% click-to-impression ratio, and compared the two.
To validate the pipeline under realistic load, we tested with the following sustained throughput:
|
Metric (per second) |
Value |
|
Impressions |
~27k |
|
Clicks |
~2.8k |
|
Combined input rate |
~30k |
|
Attributed clicks |
~2.5k |
Compute: 3 × r6gd.2xlarge workers + 1 driver (24 worker vCPUs), on DBR 18 LTS.
Latency is measured end to end, from the click's Kafka timestamp to the attributed record landing on the output Kafka topic (attributed_clicks). On the exact same code and cluster, changing only the trigger, Real-Time Mode achieves 167 ms p99 latency, about 16× faster than micro-batch mode.
In micro-batch mode, each batch executes its stages sequentially. In this case there are two stages (Kafka read, then shuffle for the join). But the bigger latency problem isn't within a single batch, it's across batches: batch N must fully complete before batch N+1 begins. With each batch taking about 1 second, a record that arrives just after batch N starts isn't picked up until batch N+1, so it can take up to 2 seconds from arriving on the input topic to landing on the output topic. That leads to an end-to-end latency spike.
Real-Time Mode executes the stages in parallel: a record is processed and joined as soon as it arrives. That's the ~16× p99 difference.
Joining two streams is one of the most common patterns in streaming, and now in Real-Time Mode, you can do it with sub-second latency. It is supported for inner joins, in update output mode, starting DBR 18 LTS.
And the pattern isn't limited to ad click attribution. Any workload that correlates two or more streams can run the same way: an inner, time-bounded join, in Real-Time Mode.
If you're running a stream-stream join in micro-batch and need lower latency, or maintaining a custom transformWithState implementation to achieve the same in RTM, try the built-in stream-stream join instead. Switching is a single trigger change, with no rewrites.
Try it yourself:
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.