This episode covers Kafka Streams: a stream processing library with KStream, KTable, and GlobalKTable, stream topologies, DSL operations like filter and aggregate, RocksDB-based state stores, windowing, and exactly-once processing guarantees.

So far you've been reading and writing data with producers and consumers. What if you want to count, join, or aggregate data as it flows? Kafka Streams is a Java library that turns Kafka into a stream processing engine — without needing a separate cluster like Flink or Spark Streaming.
Kafka Streams' advantages: it runs as an ordinary JVM application, uses Kafka as state storage, and inherits all Kafka guarantees including exactly-once. No new infrastructure; you write a topology, and the library handles parallelism, failover, and recovery.
Episode 13 covers the basic concepts of KStream, KTable, and GlobalKTable, DSL operations, stateful processing with RocksDB, windowing for time-based aggregation, and exactly-once processing guarantees.
An important distinction: Kafka Streams is a library, not a server framework. You don't run a special daemon; your application is a JVM process that uses Kafka Streams. That means standard deployment: a jar, a Docker image, or any orchestrator. One application can be scaled by running multiple instances; topic partitions are divided among the instances.
Three main abstractions represent data:
Every Streams application defines a topology: a graph of processors connecting sources (input topics), operators (filter, map, aggregate), and sinks (output topics). This topology is built with the Streams DSL, then run by KafkaStreams:
StreamsBuilder builder = new StreamsBuilder();
KStream<String, String> orders = builder.stream("orders");
orders.filter((key, value) -> value.contains("PAID"))
.mapValues(value -> value.toUpperCase())
.to("paid-orders");
KafkaStreams streams = new KafkaStreams(builder.build(), props);
streams.start();builder.stream("orders") creates a source from a topic, and each operator flows data into the next one until to() writes the result to the destination topic.
Stateless operations process one record without remembering other data:
Stateful operations need old data: groupBy and aggregate group records and compute aggregations; join combines two streams by key. An aggregation example:
KTable<String, Long> orderCount = orders
.filter((key, value) -> value.contains("PAID"))
.groupByKey()
.count(Materialized.as("order-count-store"));groupByKey().count() counts records per key and stores the result in a state store named order-count-store — a KTable whose latest value can be queried.
Aggregation and join results are stored in state stores. The default local storage is RocksDB, a fast embedded key-value database. Each application instance has a copy of the state store for the partitions it's responsible for.
Every state store has a changelog topic (with cleanup.policy=compact, see episode 10) that records every state change. When an instance crashes or a rebalance happens, the new instance restores state by reading the changelog from the last position — this is what makes stream processing fault-tolerant without losing state.
Stored state isn't only for internal use — it can be queried directly by other applications via Interactive Queries:
curl -s "http://localhost:8080/state/order-count-store/key/order-001"curl -s http://localhost:8080/state/order-count-store reads the current value from the state store. Because state is distributed, applications need to ask the metadata API where a partition lives before fetching the value — a pattern that lets Kafka Streams act as a real-time materialized view.
Time-based aggregation divides a stream into windows:
Streaming data often arrives late. The grace period determines how long late records are still accepted after a window ends. After the grace period passes, records are considered late and discarded:
KTable<Windowed<String>, Long> perMinute = orders
.groupByKey()
.windowedBy(TimeWindows.of(Duration.ofMinutes(1)).grace(Duration.ofSeconds(10)))
.count();TimeWindows.of(Duration.ofMinutes(1)) creates a one-minute tumbling window, and .grace(Duration.ofSeconds(10)) allows a ten-second lateness tolerance.
Kafka Streams integrates transactions (episode 9) natively. Configuration:
processing.guarantee=exactly_once_v2With processing.guarantee=exactly_once_v2, Streams automatically uses transactional producers and read_committed consumers: offsets, state, and output are committed in the same transaction, so reprocessing doesn't produce duplicates.
Concretely: when an instance processes a batch, all writes (to output topics and changelogs) plus consumed offsets are in one transaction. If a crash happens, the batch is reprocessed from the start but the old transaction is aborted — the result is the same, no duplicates. This is the easiest end-to-end exactly-once to achieve in the Kafka ecosystem, without writing manual transaction code.
Warning
Scaling an application by running additional instances is easy, but application.id (the application identity) must be the same across all instances so they're treated as one consumer group. Changing application.id restarts state stores from scratch.
In this episode 13 you've understood Kafka Streams as a stream processing library, the difference between KStream, KTable, and GlobalKTable, stateless and stateful operations, state stores with RocksDB and changelog topics, windowing with grace periods, and exactly-once processing.
The key takeaways:
processing.guarantee=exactly_once_v2 gives automatic end-to-end EOS.application.id.In the next episode 14 we'll discuss ksqlDB — SQL for stream processing built on top of Kafka Streams. You'll learn the difference between streams and tables, persistent, push, and pull queries, and deploying its server and CLI.