5.3 Windowed Aggregations
Module Overview
Goal: Combine state + windows once — the authoritative source for windowed aggregations.
What You'll Learn:
- Windowed keys and their structure
- Suppression (controlling output)
- Late-arriving data handling
- Emit strategies (early, on-time, late)
- Final vs intermediate results
- Practical windowed aggregation patterns
What's NOT Covered Here:
- Window types (covered in Module 5.2)
- Basic aggregations (covered in Module 5.1)
- State store internals (covered in Module 6.1)
Key Insight: Windowed aggregations combine the power of state with time boundaries to create time-based analytics.
1. Windowed Aggregations Basics
What is a Windowed Aggregation?
Combine grouping by key + grouping by time window + aggregation function:
Regular aggregation:
├─ Group by key
└─ Aggregate (count, sum, etc.)
└─ Result: KTable<K, V>
Windowed aggregation:
├─ Group by key
├─ Group by time window
└─ Aggregate (count, sum, etc.)
└─ Result: KTable<Windowed<K>, V>
Example: Clicks per User per Hour
KStream<String, ClickEvent> clicks = builder.stream("clicks");
KTable<Windowed<String>, Long> clicksPerHour = clicks
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofHours(1),
Duration.ofMinutes(5)))
.count();
// Result: Count per user per hour
// Key: Windowed<userId> (contains user + window)
// Value: count
2. Windowed Keys
Structure of Windowed<K>
Windowed<String> windowedKey = /* ... */;
// Extract components
String userId = windowedKey.key(); // Original key
Window window = windowedKey.window(); // Window object
long start = window.start(); // Window start (ms)
long end = window.end(); // Window end (ms)
Example: Processing Windowed Results
KTable<Windowed<String>, Long> windowedCounts = /* ... */;
windowedCounts.toStream().foreach((windowedKey, count) -> {
String user = windowedKey.key();
long windowStart = windowedKey.window().start();
long windowEnd = windowedKey.window().end();
System.out.printf("User %s had %d clicks from %s to %s%n",
user,
count,
Instant.ofEpochMilli(windowStart),
Instant.ofEpochMilli(windowEnd)
);
});
// Output:
// User alice had 15 clicks from 2024-01-15T10:00:00Z to 2024-01-15T11:00:00Z
// User bob had 8 clicks from 2024-01-15T10:00:00Z to 2024-01-15T11:00:00Z
3. Windowed Count
Syntax
KTable<Windowed<K>, Long> count()
What it does: Count records per key per window.
Example: Tumbling Window Count
KStream<String, Event> events = builder.stream("events");
// Count events per user per 5-minute window
KTable<Windowed<String>, Long> counts = events
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)))
.count();
// Input:
// 10:01 - user1, event
// 10:02 - user1, event
// 10:03 - user2, event
// 10:06 - user1, event ← New window!
// Output (KTable):
// Windowed<user1>[10:00-10:05] → 2
// Windowed<user2>[10:00-10:05] → 1
// Windowed<user1>[10:05-10:10] → 1
4. Windowed Reduce
Syntax
KTable<Windowed<K>, V> reduce(Reducer<V> reducer)
What it does: Reduce values within each window.
Example: Sum per Window
KStream<String, Integer> sales = builder.stream("sales");
// Sum sales per product per hour
KTable<Windowed<String>, Integer> hourlySales = sales
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofHours(1)))
.reduce((sales1, sales2) -> sales1 + sales2);
// Input (product-A):
// 10:15 → 100
// 10:30 → 50
// 10:45 → 75
// Output:
// Windowed<product-A>[10:00-11:00] → 225
5. Windowed Aggregate
Syntax
KTable<Windowed<K>, VR> aggregate(
Initializer<VR> initializer,
Aggregator<K, V, VR> aggregator,
Materialized<K, VR, WindowStore<Bytes, byte[]>> materialized
)
What it does: Custom aggregation into custom result type per window.
Example: Statistics per Window
KStream<String, Integer> values = builder.stream("values");
KTable<Windowed<String>, Stats> windowedStats = values
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(10),
Duration.ofMinutes(2)))
.aggregate(
// Initializer
() -> new Stats(0, 0, 0, 0),
// Aggregator
(key, value, stats) -> {
stats.incrementCount();
stats.addValue(value);
stats.updateMin(value);
stats.updateMax(value);
return stats;
},
// Materialized
Materialized.with(Serdes.String(), statsSerde)
);
// Result: Stats (count, sum, min, max) per key per 10-minute window
6. Suppression: Controlling Output
The Problem
Without suppression:
Window [10:00-10:05]:
├─ Event 1 arrives → emit count=1
├─ Event 2 arrives → emit count=2
├─ Event 3 arrives → emit count=3
└─ Event 4 arrives → emit count=4
Output: 4 updates for same window
└─ Downstream system receives intermediate results
└─ Extra processing, network traffic
What is Suppression?
Suppression delays emitting results until a condition is met (e.g., window closes).
With suppression:
Window [10:00-10:05]:
├─ Event 1 arrives → buffer (don't emit)
├─ Event 2 arrives → buffer (don't emit)
├─ Event 3 arrives → buffer (don't emit)
├─ Event 4 arrives → buffer (don't emit)
└─ Window closes → emit count=4
Output: 1 final result
└─ Downstream sees only complete result
Suppression Syntax
KTable<Windowed<K>, V> suppress(Suppressed<Windowed<K>> suppression)
Example: Suppress Until Window Closes
KStream<String, Event> events = builder.stream("events");
KTable<Windowed<String>, Long> finalCounts = events
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)))
.count()
.suppress(Suppressed.untilWindowCloses(
Suppressed.BufferConfig.unbounded()));
// Only emit when window is finalized (after grace period)
Suppression Strategies
// 1. Suppress until window closes (most common)
Suppressed.untilWindowCloses(BufferConfig.unbounded())
// 2. Suppress until time limit
Suppressed.untilTimeLimit(
Duration.ofMinutes(1),
BufferConfig.maxRecords(1000))
// 3. Bounded buffer (prevent memory issues)
Suppressed.untilWindowCloses(
BufferConfig.maxRecords(10000).emitEarlyWhenFull())
Suppression Trade-offs
WITH SUPPRESSION:
✅ Fewer downstream updates
✅ Only final results
✅ Cleaner output
❌ Higher latency (wait for window to close)
❌ More memory (buffer updates)
WITHOUT SUPPRESSION:
✅ Lower latency (immediate results)
✅ Less memory
❌ Many intermediate updates
❌ Downstream must handle updates
7. Handling Late-Arriving Data
Late Data Scenarios
Window: 10:00 - 10:05
Grace period: 2 minutes
Event timeline:
10:01 - Event A arrives (timestamp 10:02) ✅ On time
10:04 - Event B arrives (timestamp 10:03) ✅ On time
10:06 - Event C arrives (timestamp 10:04) ✅ Within grace (10:05 + 2)
10:08 - Window closes (10:05 + 2 + 1)
10:09 - Event D arrives (timestamp 10:03) ❌ Too late (dropped)
Grace Period Configuration
// With grace period
TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(2) // Wait 2 extra minutes
);
// Without grace period (strict)
TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5));
Window Retention Configuration
Problem: By default, window state is retained forever (or until default retention), which can cause state to grow unbounded.
Solution: Explicitly configure retention to control how long window state is kept:
// Default retention: window size + grace period
// For 5-min window + 2-min grace = 7 minutes retention
// Explicit retention using .retain()
TimeWindows window = TimeWindows
.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(2))
.retain(Duration.ofHours(2)); // Keep state for 2 hours max
// Use in aggregation
stream
.groupByKey()
.windowedBy(window)
.count();
Retention Rules:
Default Retention = window size + grace period + 24 hours (default)
Custom Retention:
├─ MUST be >= window size + grace period
├─ Controls when state store deletes old windows
└─ Balances state size vs. late data handling
Example: Optimal Retention for Different Scenarios
// Scenario 1: Real-time dashboard (keep recent data only)
TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1))
.retain(Duration.ofMinutes(10)); // Only 10 minutes of state
// Scenario 2: Hourly reports (need more history for reprocessing)
TimeWindows.ofSizeAndGrace(Duration.ofHours(1), Duration.ofMinutes(10))
.retain(Duration.ofHours(6)); // 6 hours of state
// Scenario 3: Daily aggregations (long retention for backfill)
TimeWindows.ofSizeAndGrace(Duration.ofDays(1), Duration.ofHours(1))
.retain(Duration.ofDays(7)); // 7 days of state
Impact on State Size:
Without explicit retention:
├─ State grows indefinitely
├─ Disk usage increases over time
└─ Eventually causes OOM or disk full
With explicit retention:
├─ Old windows automatically purged
├─ State size bounded
├─ Predictable resource usage
Trade-off:
├─ Shorter retention = Less state, less late data handling
└─ Longer retention = More state, better late data handling
Best Practice: Always set explicit
.retain()to avoid unbounded state growth, especially in production. Choose retention based on your late data tolerance and state size constraints.
Late Data Example
KStream<String, Event> events = builder.stream("events");
KTable<Windowed<String>, Long> counts = events
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(2))) // Accept late data for 2 minutes
.count();
// Event flow:
// 10:01 - event (ts=10:02) → counted in [10:00-10:05]
// 10:06 - event (ts=10:04) → counted in [10:00-10:05] (within grace)
// 10:08 - event (ts=10:03) → DROPPED (beyond grace period)
Detecting Dropped Records
// Log late records
events
.process(() -> new Processor<String, Event>() {
@Override
public void process(String key, Event value) {
long eventTime = value.getTimestamp();
long processingTime = context().timestamp();
long lateness = processingTime - eventTime;
if (lateness > Duration.ofMinutes(2).toMillis()) {
log.warn("Late event: key={}, lateness={}ms", key, lateness);
}
forward(key, value);
}
});
8. Emit Strategies
When to Emit Results?
THREE TIMING OPTIONS:
1. Early (eager):
└─ Emit as soon as data arrives
└─ Low latency, many updates
2. On-time (default):
└─ Emit when window closes (grace period expires)
└─ Balanced
3. Late:
└─ Only emit after final window closure
└─ Highest latency, fewest updates
Configuring Emit Strategy (via Suppression)
// Default: emit every update (early)
windowedStream.count();
// On-time: emit only when window closes
windowedStream
.count()
.suppress(Suppressed.untilWindowCloses(BufferConfig.unbounded()));
// Custom: emit periodically
windowedStream
.count()
.suppress(Suppressed.untilTimeLimit(
Duration.ofSeconds(30),
BufferConfig.maxRecords(1000)));
9. Session Window Aggregations
Unique Challenges
Session windows have dynamic boundaries:
Sessions can MERGE:
Initial state:
Session A: 10:00 - 10:03 (3 events)
Session B: 10:10 - 10:12 (2 events)
Late event at 10:06 arrives:
├─ Fills gap between sessions
└─ Sessions merge!
Merged session: 10:00 - 10:12 (6 events)
Session Window Count
KStream<String, Event> events = builder.stream("events");
KTable<Windowed<String>, Long> sessionCounts = events
.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapAndGrace(
Duration.ofMinutes(5), // Inactivity gap
Duration.ofMinutes(1))) // Grace period
.count();
// Sessions close after 5 minutes of inactivity
// Late events within 1 minute can extend/merge sessions
Session Aggregation with Merging
KTable<Windowed<String>, SessionStats> sessionStats = events
.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapWithNoGrace(
Duration.ofMinutes(5)))
.aggregate(
// Initializer
SessionStats::new,
// Aggregator: add event to session
(key, event, stats) -> {
stats.addEvent(event);
return stats;
},
// Merger: combine two sessions
(key, stats1, stats2) -> {
return stats1.merge(stats2);
},
Materialized.with(Serdes.String(), sessionStatsSerde)
);
Note: Session aggregations require a merger function!
10. Writing Windowed Results to Topics
Converting to Stream
KTable<Windowed<String>, Long> windowedCounts = /* ... */;
// Convert to stream for writing
KStream<Windowed<String>, Long> stream = windowedCounts.toStream();
// Problem: Windowed key can't be written directly to Kafka
// Solution: flatten to string key
Flattening Windowed Keys
windowedCounts
.toStream()
.map((windowedKey, count) -> {
String key = windowedKey.key();
long start = windowedKey.window().start();
long end = windowedKey.window().end();
// Create composite key
String flatKey = String.format("%s|%d|%d", key, start, end);
return KeyValue.pair(flatKey, count);
})
.to("windowed-counts-output");
// Output:
// Key: "user1|1704538800000|1704539100000"
// Value: 15
Alternative: Custom Serde
// Define custom serde for Windowed<String>
Serde<Windowed<String>> windowedSerde =
new WindowedSerdes.TimeWindowedSerde<>(Serdes.String());
windowedCounts
.toStream()
.to("windowed-counts",
Produced.with(windowedSerde, Serdes.Long()));
11. Common Patterns
Pattern 1: Hourly Metrics
// Count events per user per hour
events
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofHours(1),
Duration.ofMinutes(5)))
.count()
.suppress(Suppressed.untilWindowCloses(BufferConfig.unbounded()))
.toStream()
.to("hourly-metrics");
Pattern 2: Moving Average
// 10-minute average, updated every 2 minutes
values
.groupByKey()
.windowedBy(TimeWindows
.ofSizeAndGrace(Duration.ofMinutes(10), Duration.ofMinutes(1))
.advanceBy(Duration.ofMinutes(2)))
.aggregate(
() -> new Average(0, 0.0),
(key, value, avg) -> avg.add(value),
Materialized.with(Serdes.String(), averageSerde)
);
Pattern 3: Session Analytics
// User session statistics
clickstreams
.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapAndGrace(
Duration.ofMinutes(30),
Duration.ofMinutes(5)))
.aggregate(
SessionMetrics::new,
(userId, click, metrics) -> metrics.addClick(click),
(userId, metrics1, metrics2) -> metrics1.merge(metrics2),
Materialized.with(Serdes.String(), sessionMetricsSerde)
)
.suppress(Suppressed.untilWindowCloses(BufferConfig.unbounded()));
Pattern 4: Late Data Monitoring
// Track and log late arrivals
KStream<String, Event> events = builder.stream("events");
events.foreach((key, event) -> {
long eventTime = event.getTimestamp();
long processingTime = System.currentTimeMillis();
long lateness = processingTime - eventTime;
if (lateness > Duration.ofMinutes(1).toMillis()) {
metrics.recordLateArrival(key, lateness);
}
});
// Then do windowed aggregation
KTable<Windowed<String>, Long> counts = events
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)))
.count();
12. Performance Considerations
State Size
Windowed aggregations store:
├─ One entry per key per window
└─ State grows with: (# unique keys) × (# active windows)
Example:
├─ 100,000 unique users
├─ 5-minute windows, 1 hour retention
├─ 12 windows active (60 min / 5 min)
└─ State: 100,000 × 12 = 1.2M entries
Mitigation:
✅ Use appropriate window sizes
✅ Set retention correctly
✅ Use suppression to reduce updates
Grace Period Impact
LONGER GRACE PERIOD:
❌ Windows stay open longer
❌ More state
❌ Higher latency
✅ More accurate (accept more late data)
SHORTER GRACE PERIOD:
✅ Windows close faster
✅ Less state
✅ Lower latency
❌ Drop more late data
Suppression Memory
Suppression buffers updates in memory:
Without suppression:
├─ Update immediately written to changelog
├─ Low memory, high I/O
With suppression:
├─ Updates buffered until emission
├─ Higher memory, lower I/O
Use bounded buffers in production:
Suppressed.untilWindowCloses(
BufferConfig.maxRecords(10000).emitEarlyWhenFull())
13. Testing Windowed Aggregations
Using TopologyTestDriver
@Test
public void testWindowedCount() {
TopologyTestDriver driver = new TopologyTestDriver(topology, props);
TestInputTopic<String, Event> input =
driver.createInputTopic("events", /* serdes */);
TestOutputTopic<String, Long> output =
driver.createOutputTopic("windowed-counts", /* serdes */);
// Send events in same window
input.pipeInput("user1", new Event(), 1000L); // 00:00:01
input.pipeInput("user1", new Event(), 2000L); // 00:00:02
input.pipeInput("user1", new Event(), 3000L); // 00:00:03
// Advance time beyond window + grace period
input.pipeInput("user1", new Event(), 400000L); // 00:06:40
// Read result
KeyValue<String, Long> result = output.readKeyValue();
assertEquals(3L, result.value); // 3 events in window
}
Testing covered in Module 11.1.
14. Mental Model Summary
Windowed Aggregations
COMPONENTS:
1. Grouping
└─ groupByKey() or groupBy()
2. Windowing
└─ windowedBy(TimeWindows/SessionWindows)
3. Aggregation
└─ count() / reduce() / aggregate()
4. Suppression (optional)
└─ suppress() to control output
RESULT:
└─ KTable<Windowed<K>, V>
└─ Key contains: original key + window
└─ Value: aggregated result
Key Decisions
1. Window Type
├─ Tumbling: fixed, non-overlapping
├─ Hopping: fixed, overlapping
└─ Session: dynamic, activity-based
2. Grace Period
├─ None: strict, drop all late data
├─ Short: some tolerance
└─ Long: high tolerance, higher latency
3. Suppression
├─ None: emit every update
├─ Until window closes: emit only final
└─ Custom: balanced approach
4. Output Format
├─ Keep windowed key: for further processing
└─ Flatten key: for external systems
15. Questions You Should Be Able to Answer
After this module, you should be able to answer:
✅ What is a windowed aggregation?
✅ What does Windowed<K> contain?
✅ When should I use suppression?
✅ How do grace periods affect results?
✅ How do I handle late-arriving data?
✅ What's special about session window aggregations?
✅ How do I write windowed results to a topic?
16. What's Next?
Now that you understand windowed aggregations, the next modules will cover:
- Module 6.1: State stores — how windowed state is physically stored
- Module 6.2: Changelogs — durability and backup
- Module 6.3: Restore & recovery — handling failures
Summary
You learned:
- ✅ Windowed aggregations combine grouping, windowing, and aggregation
- ✅ Windowed<K> contains original key + window start/end times
- ✅ Suppression controls when results are emitted (reduces downstream updates)
- ✅ Grace periods allow late data with latency trade-off
- ✅ Session windows require special merger function
- ✅ Writing results requires flattening windowed keys
- ✅ Performance depends on window size, grace period, and suppression strategy
Next up: Module 6.1 — State Stores Explained — Learn how Kafka Streams physically stores and manages state.