Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Kafka Streams Windowing

Windowing groups records into finite sets based on time, enabling time-bounded aggregations and joins. This guide covers window types, time semantics, and late arrival handling.


Time TypeDescriptionUse Case
Event timeTimestamp embedded in recordBusiness logic, reprocessing
Processing timeWall-clock time when processedSimple cases, debugging
Ingestion timeTime when record enters KafkaProxy for event time
// Extract event time from record
Consumed<String, Event> consumed = Consumed.with(Serdes.String(), eventSerde)
.withTimestampExtractor(new TimestampExtractor() {
@Override
public long extract(ConsumerRecord<Object, Object> record, long partitionTime) {
Event event = (Event) record.value();
return event.getTimestamp();
}
});
KStream<String, Event> stream = builder.stream("events", consumed);

Fixed-size, non-overlapping windows:

Eventse1e2e3e4e5e6WindowsWindow 1 [0-5)Window 2 [5-10)Window 3 [10-15)0256810
// 5-minute tumbling windows
KTable<Windowed<String>, Long> tumblingCounts = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5)))
.count();
// With grace period for late arrivals
KTable<Windowed<String>, Long> tumblingWithGrace = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1) // Accept late records up to 1 minute
))
.count();

Fixed-size, overlapping windows:

Time05Window AActiveWindow BActiveWindow CActive024579
// 5-minute windows, advancing every 2 minutes
KTable<Windowed<String>, Long> hoppingCounts = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1))
.advanceBy(Duration.ofMinutes(2)))
.count();

Window around each record, used for joins:

// Join events within 5 minutes of each other
KStream<String, EnrichedOrder> joined = orders.join(
payments,
(order, payment) -> new EnrichedOrder(order, payment),
JoinWindows.ofTimeDifferenceWithNoGrace(Duration.ofMinutes(5)),
StreamJoined.with(Serdes.String(), orderSerde, paymentSerde)
);
// Sliding windows for aggregations (Kafka 2.7+)
KTable<Windowed<String>, Long> slidingCounts = stream
.groupByKey()
.windowedBy(SlidingWindows.ofTimeDifferenceAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)
))
.count();

Dynamic windows based on activity gaps:

Eventse1e2e3e4e5SessionsSession 1(gap > 3min)Session 2(gap > 3min)0236101215
// Session windows with 5-minute inactivity gap
KTable<Windowed<String>, Long> sessionCounts = stream
.groupByKey()
.windowedBy(SessionWindows.ofInactivityGapAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)
))
.count();

Window TypeSizeOverlapUse Case
TumblingFixedNonePeriodic reports
HoppingFixedYesSmoothed metrics
SlidingFixedYesCorrelation analysis
SessionVariableNoneUser sessions

Configure how long to accept late records:

// Accept records up to 1 minute late
TimeWindows.ofSizeAndGrace(
Duration.ofMinutes(5),
Duration.ofMinutes(1)
);
// No grace - reject all late records (default)
TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(5));
StreamWindow .0-5.AggregationStreamStreamWindow [0-5)Window [0-5)AggregationAggregatione1 @ t=2Update countcount=1e2 @ t=4Update countcount=2e3 @ t=1 (late!)Within grace periodUpdate countcount=3e4 @ t=0 (very late!)Outside grace periodDropped

Control when windowed results are emitted:

// Emit only final results
KTable<Windowed<String>, Long> finalCounts = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.count()
.suppress(Suppressed.untilWindowCloses(BufferConfig.unbounded()));
// Emit final results with bounded buffer
KTable<Windowed<String>, Long> boundedFinal = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.count()
.suppress(Suppressed.untilWindowCloses(
BufferConfig.maxBytes(1_000_000L) // 1MB buffer
.shutDownWhenFull()
));
// Emit intermediate results with rate limiting
KTable<Windowed<String>, Long> rateLimited = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.count()
.suppress(Suppressed.untilTimeLimit(
Duration.ofSeconds(30),
BufferConfig.unbounded()
));

KTable<Windowed<String>, Long> windowedCounts = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.count(
Materialized.<String, Long, WindowStore<Bytes, byte[]>>as("windowed-counts")
.withRetention(Duration.ofHours(1))
);
KTable<Windowed<String>, Double> maxValues = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.reduce(
(v1, v2) -> Math.max(v1, v2),
Materialized.with(Serdes.String(), Serdes.Double())
);
KTable<Windowed<String>, Statistics> stats = stream
.groupByKey()
.windowedBy(TimeWindows.ofSizeAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1)))
.aggregate(
Statistics::new,
(key, value, stats) -> stats.add(value),
Materialized.<String, Statistics, WindowStore<Bytes, byte[]>>as("stats-store")
.withValueSerde(statisticsSerde)
);

KTable<Windowed<String>, Long> windowedCounts = /* ... */;
// Convert to stream for further processing
KStream<Windowed<String>, Long> countsStream = windowedCounts.toStream();
// Extract window information
countsStream.foreach((windowedKey, count) -> {
String key = windowedKey.key();
Window window = windowedKey.window();
long start = window.start();
long end = window.end();
System.out.printf("Key: %s, Window: [%d, %d), Count: %d%n",
key, start, end, count);
});
// Change key to include window info
KStream<String, Long> flatCounts = countsStream.map((windowedKey, count) -> {
String newKey = windowedKey.key() + "-" + windowedKey.window().start();
return KeyValue.pair(newKey, count);
});
// For output topics
countsStream.to(
"windowed-counts",
Produced.with(
WindowedSerdes.timeWindowedSerdeFrom(String.class, Duration.ofMinutes(5).toMillis()),
Serdes.Long()
)
);
// Custom windowed key serde
Serde<Windowed<String>> windowedSerde = new WindowedSerdes.TimeWindowedSerde<>(
Serdes.String(),
Duration.ofMinutes(5).toMillis()
);

// Materialized store with retention
Materialized.<String, Long, WindowStore<Bytes, byte[]>>as("windowed-store")
.withRetention(Duration.ofHours(24)); // Keep windows for 24 hours
// Retention must be >= window size + grace period
// retention >= size + grace
// Configure changelog topic retention
props.put(StreamsConfig.topicPrefix("windowstore.changelog") + "retention.ms",
String.valueOf(Duration.ofHours(24).toMillis()));

ReadOnlyWindowStore<String, Long> store = streams.store(
StoreQueryParameters.fromNameAndType(
"windowed-counts",
QueryableStoreTypes.windowStore()
)
);
// Fetch for specific window
Instant windowStart = Instant.now().minus(Duration.ofMinutes(5));
Long count = store.fetch("key", windowStart.toEpochMilli());
// Fetch all windows in time range
Instant from = Instant.now().minus(Duration.ofHours(1));
Instant to = Instant.now();
try (WindowStoreIterator<Long> iter = store.fetch("key", from, to)) {
while (iter.hasNext()) {
KeyValue<Long, Long> kv = iter.next();
long windowStart = kv.key;
long count = kv.value;
System.out.printf("Window starting %d: count=%d%n", windowStart, count);
}
}
// Fetch all keys in time range
try (KeyValueIterator<Windowed<String>, Long> iter = store.fetchAll(from, to)) {
while (iter.hasNext()) {
KeyValue<Windowed<String>, Long> kv = iter.next();
// Process each windowed key-value
}
}

ConsiderationGuidance
Latency requirementsSmaller windows = faster results
Data volumeLarger windows = fewer outputs
Late arrivalsGrace period adds latency
Memory usageMore windows = more memory
PracticeRecommendation
Limit retentionKeep only necessary history
Use suppressionReduce output volume
Appropriate graceBalance completeness vs latency
Monitor state sizeAlert on excessive growth
// Metrics per minute with 1-hour retention
TimeWindows.ofSizeAndGrace(Duration.ofMinutes(1), Duration.ofSeconds(30))
// Materialized with 1-hour retention
// User sessions with 30-minute timeout
SessionWindows.ofInactivityGapAndGrace(Duration.ofMinutes(30), Duration.ofMinutes(5))
// Sliding average over 5-minute window
SlidingWindows.ofTimeDifferenceAndGrace(Duration.ofMinutes(5), Duration.ofMinutes(1))