Kafka Streams Part 2: KStreams, KTables, Joins and Windows
Kafka Streams: From Zero to Production(3-part series)
- P1Kafka Streams Part 1: Stream Processing Made Simple
- P2Kafka Streams Part 2: KStreams, KTables, Joins and Windows(here)
- P3Kafka Streams Part 3: Making It Production-Ready

In Part 1 we built a word counter and quietly used two types without really explaining them: KStream and KTable. Time to fix that, because this is the most important concept in all of Kafka Streams. Get these two right, and everything else — joins, windows, state — falls into place naturally.
Here's the one-line version:
A KStream is a diary. A KTable is a whiteboard.
Let's make that concrete.
KStream: The Diary
A diary records events as they happen. You never go back and erase an old page — you just write a new line. Every entry matters on its own.
A KStream is exactly that: a sequence of records where every record is a fact that happened.
(user-1, clicked /shoes) ← happened
(user-2, clicked /shirts) ← happened
(user-1, clicked /cart) ← happened
(user-1, clicked /shoes) ← happened AGAIN — and that's fineTwo clicks on /shoes from user-1 are not a mistake. They are two separate events, and both belong in the stream. Nothing is overwritten, ever. Use a KStream when the event itself is the information: clicks, payments, sensor readings, log lines.
KTable: The Whiteboard
A whiteboard holds current status. When the status changes, you erase the old value and write the new one. Nobody cares what the whiteboard said yesterday.
A KTable is a table where each record updates the row for its key: only the latest value per key survives.
(user-42, city: Dhaka) ← later...
(user-42, city: Singapore) ← ...erases "Dhaka". Now the table says Singapore.
(user-99, city: Berlin)Reading the same key twice leaves the KTable unchanged — the second write just replaces the first. Use a KTable for current state: a user's profile, today's product price, a device's last known status.
| Question | KStream (diary) | KTable (whiteboard) |
|---|---|---|
| Each record means... | "this happened" | "this is now true" |
| Same key arrives twice | Both records kept | Second replaces the first |
Record with null value | Just an odd record | A tombstone — deletes the key |
| Good for | Clicks, orders, readings | Profiles, prices, status |
| Updated over time? | No, it just grows | Yes — it's "current state" |
Rule of thumb: if you'd put the event in an append-only audit log, use a KStream. If you'd INSERT ... ON CONFLICT UPDATE it into a database row, use a KTable.
The Trick That Makes KTables Possible
How can a table "erase" values when Kafka topics only ever grow? Two Kafka features do the magic:
Log compaction. A normal topic keeps records for N days and deletes them by age. A compacted topic instead promises: for every key, keep at least the latest value, forever. Old records for the same key get cleaned up in the background. So the whole topic is effectively the table.
Tombstones. A record with a null value means "delete this key". Compaction keeps tombstones around long enough for everyone to see them, then drops the key entirely.
So a KTable is really just a live view over a compacted topic. Beautiful.
Bonus: GlobalKTable
One variation worth knowing: a GlobalKTable gives every instance of your app a full copy of the table, instead of splitting it into partitions. It costs more memory, but it lets you join against data with any key (not just the record's key) — perfect for small reference data like country codes or product catalogs.
Stateless Operations: One Record In, Records Out
These transform records without any memory of the past. If you know Java streams, you already know most of them.
Say we have a stream of payment events, keyed by user:
KStream<String, Payment> payments = builder.stream("payments");filter — keep only what matches. Every record is judged alone.
// Only suspicious payments
KStream<String, Payment> suspicious = payments
.filter((user, payment) -> payment.amount() > 1000);mapValues — transform the value, leave the key alone. (Prefer this over map when you can — keeping the key means keeping the partitioning.)
KStream<String, String> descriptions = payments
.mapValues(p -> p.amount() + " " + p.currency());flatMapValues — one value in, many values out (we used this in Part 1 to split sentences into words).
// A receipt with 3 items becomes 3 records
KStream<String, Item> items = receipts.flatMapValues(r -> r.items());selectKey — assign a new key (careful: this breaks "same key → same partition", which matters below).
KStream<String, Payment> byCountry = payments
.selectKey((user, p) -> p.country());branch — split one stream into several by predicate, like sorting mail into boxes:
Map<String, KStream<String, Payment>> split = payments
.split()
.branch((k, p) -> p.amount() > 1000, Branched.as("large"))
.branch((k, p) -> p.amount() < 10, Branched.as("tiny"))
.defaultBranch(Branched.as("normal"));
KStream<String, Payment> large = split.get("large");
KStream<String, Payment> normal = split.get("normal");merge — the opposite: combine two streams of the same type into one.
peek — look at records without changing them. Perfect for logging and debugging:
payments.peek((user, p) ->
log.info("saw payment from {} of {}", user, p.amount()));peek is for observing, not processing. If you find yourself sending emails or writing to databases inside peek, stop — side effects there make your app behave unpredictably after restarts. Process data with the proper operations instead.
Stateful Operations: Where Memory Comes In
Now the fun part. To count, sum, or find the max, you need to remember the past. Kafka Streams calls this state, and it powers the operations below.
But state has a precondition: records must be grouped by key — because remembering something "per key" only works when all records for a key land in the same place (golden rule from Part 1: same key → same partition).
groupByKey vs groupBy
payments.groupByKey() // key stays as-is → free
payments.groupBy((k, v) -> v.country()) // NEW key → Kafka Streams
// creates a hidden
// "repartition" topic behind
// the scenes and re-shuffles
// records through itRepartitioning is invisible but real. When you change keys with groupBy, Kafka Streams silently writes all records to a new internal topic and reads them back, correctly partitioned. It costs an extra write+read of your whole stream. If your records are already keyed the way you want, use groupByKey and skip the toll booth.
count — How Many?
You met this in Part 1:
KTable<String, Long> paymentsPerUser = payments
.groupByKey()
.count();aggregate — The Swiss Army Knife
count is just a special case of aggregate. You give it a starting value and a function that folds each new record into the running result:
// Total money spent per user
KTable<String, Double> totalByUser = payments
.groupByKey()
.aggregate(
() -> 0.0, // 1. starting value
(user, payment, total) -> total + payment.amount(), // 2. fold in each record
Materialized.as("user-totals") // 3. name the state store
.withValueSerde(Serdes.Double()) // (and how to store it)
);Walking through it with three payments of 10, 20, and 5 from user-1:
| Event | Running total | Emitted update |
|---|---|---|
user-1 pays 10 | 0.0 + 10 = 10.0 | (user-1, 10.0) |
user-1 pays 20 | 10.0 + 20 = 30.0 | (user-1, 30.0) |
user-1 pays 5 | 30.0 + 5 = 35.0 | (user-1, 35.0) |
Every step emits the new current value — KTable behavior. That's why consumers of this topic can always just read the latest record per key.
Why name the state store? Materialized.as("user-totals") gives your store a stable, readable name. Kafka Streams backs it up to a changelog topic named after it. Without a name you get auto-generated ones like ...-KSTREAM-AGGREGATE-STATE-STORE-0000000003 — which change when you edit your code and break recovery from old data. Name your stores. Future-you says thanks.
reduce — Combine New with Old
reduce is aggregate simplified: no separate starting value, just "how do I merge the new record into what I have?"
// The largest payment seen per user
KTable<String, Payment> biggestPayment = payments
.groupByKey()
.reduce((biggestSoFar, newPayment) ->
newPayment.amount() > biggestSoFar.amount()
? newPayment
: biggestSoFar);Joins: SQL Power, Streaming Speed
Real apps combine data from multiple topics. Kafka Streams joins work like SQL joins — the same key on both sides is the matchmaker — but with streaming semantics. Three combinations matter.
Stream + Table (the classic: enrich events with state)
Click events stream by, but a click alone is thin. Join it with the user profile table to fatten it up:
KStream<String, Click> clicks = builder.stream("clicks"); // key: userId
KTable<String, User> users = builder.table("users"); // key: userId
KStream<String, EnrichedClick> enriched = clicks.join(
users,
(click, user) -> new EnrichedClick(user.name(), user.country(), click.url())
);
// output key: userId → value: "Maria from PT clicked /checkout"How it behaves: each arriving click looks up users at that moment. If user-42's profile changes at 10:05, clicks before that see the old profile, clicks after see the new one. The stream is history; the table is "now" — the join combines them exactly the way you'd expect.
Table + Table (materialized SQL join)
Two tables join like two database tables, and the result updates whenever either side changes:
KTable<String, User> users = builder.table("users"); // key: userId
KTable<String, Plan> plans = builder.table("subscriptions"); // key: userId
KTable<String, String> userPlan = users.join(
plans,
(user, plan) -> user.name() + " is on the " + plan.tier() + " plan"
);Change someone's plan, and the joined table re-emits userId → new description automatically.
Stream + Stream (needs a time limit!)
Two infinite streams have no defined "end", so a join between them must be windowed — otherwise Kafka Streams would have to remember one side forever:
KStream<String, Impression> impressions = builder.stream("ad-impressions");
KStream<String, Click> adClicks = builder.stream("ad-clicks");
// Pair each ad impression with a click on it within 10 seconds
KStream<String, String> conversions = impressions.join(
adClicks,
(impression, click) -> "ad " + impression.adId() + " was clicked",
JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofSeconds(10))
);Impression at 12:00:00, click at 12:00:07 → match, one joined record out. Click at 12:01:00 → no match, the impression's window is long closed.
Which join do I pick? Event + reference data → stream-table. Two current-states → table-table. Two event timelines (impression/click, order/shipment) → stream-stream with a window.
Windows: Slicing an Endless Stream
"How many clicks per minute?" sounds simple, but a stream never ends — so per minute of what? We need to slice time into windows and compute per slice. Four kinds to know:
Tumbling Windows — Fixed, Non-Overlapping Chunks
The clock cuts time into equal back-to-back blocks: 12:00–12:01, 12:01–12:02, ... Each event falls into exactly one block.
time → 12:00 12:01 12:02 12:03
events: x x x | x | x x x x |
windows: [——— 1 min ———][——— 1 min ———][——— 1 min ———]
output: 3 1 4KStream<Windowed<String>, Long> clicksPerMinute = clicks
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
.count()
.toStream();Note the key type: Windowed<String> — it's the key plus its window (like user-42 @[12:01, 12:02)), because the same user now has a count in every window.
Seeing TimeWindows.of(...) in older tutorials? That's the pre-3.0 API. Modern versions use ofSizeWithNoGrace(...) (no grace period) or ofSizeAndGrace(size, grace) — same idea, clearer name.
Hopping Windows — Fixed Size, Slides Forward
Same block size, but the window hops forward by a smaller step, so blocks overlap and each event lands in several windows:
size = 5 min, hop = 1 min:
[——————— window ———————]
[——————— window ———————]
[——————— window ———————] ← each advances 1 minUse it for smoothed metrics — "a moving 5-minute count, updated every minute":
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofSeconds(5))
.advanceBy(Duration.ofMinutes(1)))Sliding Windows — Sized Around Each Event
Instead of fixed grid positions, the window snaps around each event, covering [eventTime − 5s, eventTime + 5s]. It's mostly used inside stream-stream joins (JoinWindows from our ad example works exactly this way) — "did a matching event happen within 5 seconds of this one?"
Session Windows — Closed by Inactivity
No fixed size at all. A session grows while events keep arriving, and closes after a gap of silence. Perfect for user activity bursts:
clicks: x x x x x x
gap: |—— 40 min of silence ——|
session: [—— session 1 ——] [—2—] [—3—]KTable<Windowed<String>, Long> sessions = clicks
.groupByKey()
.windowedBy(SessionWindows.with(Duration.ofMinutes(30)))
.count();With a 30-minute gap setting: 3 clicks, a 40-minute pause, then 2 more clicks → one session of 3 and one session of 2 (if more clicks had arrived at minute 29, they'd have merged into the first session instead).
Emitting Final Results Only: suppress
One annoyance with windows: by default you get an update for every record (count is 1... 2... 3...) and the final value only when the window closes. For things like "alerts per minute, emitted once at the end", suppress the intermediate updates:
KStream<Windowed<String>, Long> finalCounts = clicks
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(1), Duration.ofSeconds(10)))
.count()
.suppress(Suppressed.untilWindowCloses(BufferConfig.unbounded())) // ← wait for close
.toStream();Now each window emits exactly one record: the final count, once the window plus its grace period has passed.
Where Does the State Actually Live?
We've been casually accumulating state all part — counts, sums, sessions. Where is it? Answer: right next to your code, with a backup in Kafka.
The design is a clever double-win:
- Fast: every lookup and update hits RocksDB on local disk — no network round trip to any database.
- Durable: every state change also goes to a compacted changelog topic in Kafka. If an instance dies, a new one rebuilds its store by replaying the changelog. Kafka itself is your state's backup system.
You never manage any of this. Name your stores well, and it just works.
What's Next?
You now hold the full mental model: streams as events, tables as state, joins to combine them, and windows to slice time. Part 3 turns that knowledge into something you'd bet production on:
- Custom Serdes for your own JSON classes (and the trap that crashes apps at 3 AM)
- Event time vs processing time, and why it decides which window a record belongs to
- Exactly-once processing with one config line
- Error handling: poison pills and dead-letter queues
- Scaling with threads, instances, and standby replicas
- Testing topologies without a Kafka broker
Continue to Part 3: Making It Production-Ready →
Key Takeaways from Part 2:
- KStream = diary (every record is a fact), KTable = whiteboard (latest value per key)
- KTables ride on log compaction + tombstones — the topic is the table
groupBywith a new key creates a hidden repartition topic — usegroupByKeywhen keys already matchaggregateis the general stateful tool;countandreduceare its convenient shortcuts- Always name state stores with
Materialized.as("...")— auto-generated names break across code changes- Stream-Table joins enrich events; Table-Table joins mirror SQL; Stream-Stream joins must be windowed
- Window types: tumbling (fixed grid), hopping (overlapping slides), sliding (around events), session (closed by inactivity)
- State lives in local RocksDB, backed up to changelog topics in Kafka — fast and durable
Tags
Related Posts

Kafka Streams Part 3: Making It Production-Ready
Take Kafka Streams to production — custom Serdes, event time, exactly-once processing, error handling with dead-letter queues, scaling, testing, and the mistakes everyone makes.

Kafka Streams Part 1: Stream Processing Made Simple
Learn what Kafka Streams is, why it exists, and build your first real-time word counting app — explained in plain words with working code you can run today.

Kafka Streams with Spring Boot: Real-Time Pipelines the Spring Way
Build a production-shaped streaming app with Spring Boot and Spring Kafka — REST input, JSON events, running aggregates, and windowed fraud alerts, with Spring managing all the plumbing.