You cannot sum an infinite stream. That sounds obvious until you try it. A stream of orders, sensor readings, or page views never ends, so "total revenue" or "average temperature" has no answer unless you first say over what period. Windowing is how you say it: you cut an unbounded stream into bounded chunks so that aggregation has a beginning and an end.
This post covers the three window types in the Cortex Data Framework — tumbling, sliding, and session — with runnable C# for each, then the parts people skip until production hurts: triggers, late and out-of-order data, and how to pick a window size that does not lie to you.
Why windows exist
A streaming aggregate is a question with an implied time bound. "How many requests did this client make?" is unanswerable; "how many in the last minute?" is a number you can compute, emit, and act on. Windows give you that bound. Concretely, they let you:
- Aggregate over time — sums, averages, counts for a defined period.
- Bound memory — process and release data in chunks instead of holding the entire stream.
- Emit on a schedule — produce results at window close, or earlier if you ask.
Every Cortex window is keyed. A key selector partitions the stream (per sensor, per customer, per URL) so each key gets its own independent windows, and a timestamp selector decides which window an event falls into. Both are required, and both matter more than they look — more on the timestamp choice at the end.
The three window types
| Window type | Size | Overlap | Boundaries | Events per window | Best for |
|---|---|---|---|---|---|
| Tumbling | Fixed | None | Time-based | Exactly one | Periodic reports, batch aggregations |
| Sliding | Fixed | Yes | Time-based | Multiple | Moving averages, trend detection |
| Session | Variable | None | Activity-based | Exactly one | User sessions, activity tracking |
The intuition:
- A tumbling window chops time into adjacent, non-overlapping slices of equal length. Each event lands in exactly one slice. Think "every 5 minutes, on the dot."
- A sliding window is also fixed-length, but a new window starts every
slideInterval, so windows overlap and a single event can belong to several of them. Think "the last 5 minutes, recomputed every minute." - A session window has no fixed length at all. It opens when activity starts, extends with each new event, and closes after a gap of silence — the
inactivityGap. Think "this user's browsing session, however long it happened to be."
Every window, regardless of type, emits a WindowResult<TKey, TValue> with the same shape:
public class WindowResult<TKey, TValue>
{
public TKey Key { get; } // partition key
public DateTime WindowStart { get; }
public DateTime WindowEnd { get; }
public IReadOnlyList<TValue> Items { get; } // the buffered events
public WindowEmissionType EmissionType { get; } // Early, OnTime, Late, Retraction
public bool IsFinal { get; } // true if the window is closed
public DateTime EmissionTime { get; }
public int EmissionSequence { get; }
}
You read Key, Items, WindowStart, and WindowEnd in a .Map(...) to produce your aggregate, then .Sink(...) it somewhere. The other fields (EmissionType, IsFinal, EmissionSequence) come into play once you start using advanced windows with early triggers and late data.
Tumbling windows
Tumbling is the workhorse: fixed slices, every event in exactly one window. Here is a 15-minute traffic count per page URL.
using Cortex.Streams;
using Cortex.Streams.Operators.Windows;
using System;
using System.Linq;
public record PageView(string PageUrl, string UserId, DateTime Timestamp, string Country);
var stream = StreamBuilder<PageView>
.CreateNewStream("Traffic Analytics")
.Stream()
.TumblingWindow<string>(
keySelector: pv => pv.PageUrl,
timestampSelector: pv => pv.Timestamp,
windowSize: TimeSpan.FromMinutes(15))
.Map(window => new
{
PageUrl = window.Key,
ViewCount = window.Items.Count,
UniqueVisitors = window.Items.Select(pv => pv.UserId).Distinct().Count(),
WindowStart = window.WindowStart,
WindowEnd = window.WindowEnd
})
.Sink(r => Console.WriteLine(
$"{r.PageUrl}: {r.ViewCount} views, {r.UniqueVisitors} unique " +
$"[{r.WindowStart:HH:mm}-{r.WindowEnd:HH:mm}]"))
.Build();
stream.Start();
Note the using System.Linq; — .Count, the property, is on IReadOnlyList, but .Select(...).Distinct().Count() are LINQ extension methods and will not compile without it.
The method signature is .TumblingWindow<TKey>(keySelector, timestampSelector, windowSize), with two optional trailing parameters: stateStoreName (a string) to name the underlying store, and stateStore to supply your own instance for durability. By default the window buffers in memory.
The flow, with events e1..e6 falling into two consecutive 15-minute windows, looks like this:
Each event belongs to exactly one window, and the aggregate fires when that window's end time passes.
Sliding windows
A sliding window adds a slideInterval: a new window starts every interval, so windows overlap and the same event contributes to several of them. This is what gives you a smooth moving average instead of the sawtooth you get from tumbling.
using Cortex.Streams;
using Cortex.Streams.Operators.Windows;
using System;
using System.Linq;
public record StockPrice(string Symbol, decimal Price, DateTime Timestamp);
var stream = StreamBuilder<StockPrice>
.CreateNewStream("Stock Moving Average")
.Stream()
.SlidingWindow<string>(
keySelector: sp => sp.Symbol,
timestampSelector: sp => sp.Timestamp,
windowSize: TimeSpan.FromMinutes(5),
slideInterval: TimeSpan.FromMinutes(1)) // new window every minute
.Map(window => new
{
Symbol = window.Key,
MovingAverage = window.Items.Average(p => p.Price),
High = window.Items.Max(p => p.Price),
Low = window.Items.Min(p => p.Price),
DataPoints = window.Items.Count,
WindowEnd = window.WindowEnd
})
.Sink(q => Console.WriteLine(
$"{q.Symbol}: {q.MovingAverage:F2} " +
$"(H {q.High:F2}, L {q.Low:F2}) @ {q.WindowEnd:HH:mm:ss}"))
.Build();
stream.Start();
The signature is .SlidingWindow<TKey>(keySelector, timestampSelector, windowSize, slideInterval), again with optional stateStoreName and stateStore. One rule the docs are explicit about: slideInterval must be less than or equal to windowSize — otherwise you would have gaps, which is just a misconfigured tumbling window.
The cost is memory. Each event lives in roughly windowSize / slideInterval windows simultaneously. A 10-minute window sliding every minute means each event is held in about ten overlapping windows per key. That overlap factor is the number to keep in your head when you size state stores: a 1-hour window sliding every 5 minutes duplicates each event across ~12 windows.
Session windows
Session windows are the odd one out: no fixed length. A session opens on the first event for a key, every subsequent event resets the inactivity timer, and the session closes once inactivityGap elapses with no new events. The window's actual duration is whatever the activity dictated.
using Cortex.Streams;
using Cortex.Streams.Operators.Windows;
using System;
using System.Linq;
public record UserAction(string UserId, string Action, DateTime Timestamp);
var stream = StreamBuilder<UserAction>
.CreateNewStream("User Session Tracker")
.Stream()
.SessionWindow<string>(
keySelector: a => a.UserId,
timestampSelector: a => a.Timestamp,
inactivityGap: TimeSpan.FromMinutes(30)) // session ends after 30 min idle
.Map(session => new
{
UserId = session.Key,
SessionStart = session.WindowStart,
SessionEnd = session.WindowEnd,
Duration = session.WindowEnd - session.WindowStart,
ActionCount = session.Items.Count,
Actions = session.Items.Select(a => a.Action).ToList()
})
.Sink(s => Console.WriteLine(
$"Session for {s.UserId}: {s.ActionCount} actions over " +
$"{s.Duration.TotalMinutes:F1} min"))
.Build();
stream.Start();
The signature is .SessionWindow<TKey>(keySelector, timestampSelector, inactivityGap), with the same optional stateStoreName / stateStore parameters. The inactivityGap is the only knob, and it is entirely domain-specific: 30 minutes is the classic web-session timeout, mobile apps run shorter (5–15 minutes) because users switch apps constantly, and a shopping cart might warrant 20–30 minutes. Too short and a single real session splinters into fragments; too long and sessions stay open — consuming memory and delaying results — well after the user has gone.
Triggers, late data, and grace
The basic windows above use the default trigger: emit once, when the window's end time is reached. That is fine for a nightly report. It is not fine when you have a 1-hour window and a dashboard that should not be frozen for an hour, or when events arrive out of order and a straggler shows up after its window closed. For those cases, switch to the advanced variants — AdvancedTumblingWindow, AdvancedSlidingWindow, AdvancedSessionWindow — which take a WindowConfiguration<TInput>.
Triggers control when a window emits
A trigger evaluation returns one of three results: Continue (keep buffering), Fire (emit now, keep the window open), or FireAndPurge (emit and close). The built-in triggers cover most needs:
| Trigger | Fires on | Best for |
|---|---|---|
EventTimeTrigger (default) | Window end | Simple windowing |
CountTrigger | Every N elements | Guaranteed batch sizes |
ProcessingTimeTrigger | Wall-clock intervals | Regular dashboard updates |
EarlyTrigger | Intervals + window end | Long windows needing live updates |
OrTrigger / AndTrigger | First / both conditions | Latency vs. batch-size guarantees |
CustomTrigger | Your logic | Complex business rules |
An early trigger gives partial results during a long window and a final result when it closes:
using Cortex.Streams;
using Cortex.Streams.Operators.Windows;
using System;
using System.Linq;
public record Transaction(string AccountId, decimal Amount, DateTime Timestamp);
var config = WindowConfiguration<Transaction>.Create()
.WithEarlyTrigger(TimeSpan.FromMinutes(5)) // partial result every 5 min
.WithStateMode(WindowStateMode.Accumulating) // include all data each time
.Build();
var stream = StreamBuilder<Transaction>
.CreateNewStream("Hourly Transaction Analysis")
.Stream()
.AdvancedTumblingWindow<string>(
keySelector: t => t.AccountId,
timestampSelector: t => t.Timestamp,
windowSize: TimeSpan.FromHours(1),
config: config)
.Map(window =>
{
var label = window.IsFinal ? "FINAL" : $"UPDATE #{window.EmissionSequence}";
var sum = window.Items.Sum(t => t.Amount);
return $"[{label}] Account {window.Key}: {sum:N2}";
})
.Sink(Console.WriteLine)
.Build();
The state mode that pairs with the trigger decides what Items contains on each emission. Discarding (the default) gives you only the events since the last fire — good for incremental batches. Accumulating gives you everything since the window opened — what you want for running totals, and almost always what you want with an early trigger. AccumulatingAndRetracting goes further: before each updated result it emits a Retraction so downstream systems can delete the previous value before applying the new one, which is how you keep a materialized view correct.
Allowed lateness handles out-of-order events
Events do not arrive in timestamp order. A reading stamped 11:58 can show up at 12:01, after its 11:45–12:00 window has already fired. Allowed lateness is the grace period during which Cortex will still accept such stragglers, reopen the window, and emit an updated result tagged WindowEmissionType.Late. Anything later than the grace period is dropped and routed to your late-event callback so you can log it, store it for batch reprocessing, or send it to a dead-letter queue — not lose it silently.
var config = WindowConfiguration<Transaction>.Create()
.WithAllowedLateness(TimeSpan.FromMinutes(5)) // accept up to 5 min late
.OnLateEvent((txn, timestamp) =>
Console.WriteLine($"Dropped late event at {timestamp}: {txn.AccountId}"))
.Build();
The timeline is straightforward: if a window ends at 10:00 with 5 minutes of allowed lateness, an event arriving at 10:04 is folded in and triggers a late emission; an event arriving at 10:06 is past grace and goes to OnLateEvent. The WindowEmissionType on each result — Early, OnTime, Late, or Retraction — tells your sink exactly how to treat it.
Common pitfalls
Choosing the timestamp field. The timestampSelector is the single most consequential decision in a windowing pipeline. Point it at the field that records when the event actually happened — the sensor's reading time, the order's checkout time — not when your code happened to see it.
Event time vs. processing time. Using timestampSelector: _ => DateTime.UtcNow is processing time: simple, but it lies the moment there is any delay between an event occurring and being processed. A batch of readings replayed from a queue would all collapse into "now" and land in the wrong window. Event time (the timestamp carried on the event) is correct, but it forces you to deal with lateness — which is exactly what allowed lateness is for. Choose processing time only when approximate, real-time-ish grouping is genuinely good enough.
Forgetting that late data is normal. Out-of-order arrival is the default in any distributed system, not an edge case. If you use basic windows with no allowed lateness, stragglers are silently discarded. Measure how late your data actually arrives, set WithAllowedLateness to cover the bulk of it, and always implement OnLateEvent so the rest is captured rather than lost.
Window sizing. Too small and you get noisy, statistically meaningless aggregates and a flood of emissions; too large and insights arrive late and memory balloons — Accumulating windows hold every event until they close. For sliding windows, remember the overlap factor (windowSize / slideInterval): a generous window with a tiny slide multiplies your memory per key. Size for the question you are answering, then add an early trigger if a long window would otherwise leave a dashboard stale.
Where to go next
Windowing is the foundation for almost everything else interesting in stream processing — joins, aggregations, anomaly detection. Start with a basic tumbling window, get the timestamp selector right, then reach for the advanced variants when you need triggers or late-data handling.
- Docs — cortex.buildersoft.io/get-started/home
- GitHub — github.com/buildersoftio/cortex
- Recipes — on-site recipes
- Discord — join the community