Rolling Windows
Sliding-window computations over ordered events. For each event in the source, look at a window of nearby events and compute a reducer.
const rollingAvg = cpu.rolling('5m', { cpu: 'avg' });
// rollingAvg.length === cpu.length; each output's cpu is the
// trailing-5-minute average ending at that event.
Same reducer vocabulary as aggregate — every
built-in works, plus custom functions. The difference is per-event
output cadence vs. one-per-bucket. For the mental model placing
rolling alongside aggregate, reduce, and the streaming variant,
see Concepts → Windowing.
Window alignment
What "nearby" means is the alignment. Pick the one that matches the question:
ASCII first pass; Excalidraw replacement welcome.
source events ● ● ● ● ● ● ● ● ● ● ● ● ● ● ● ●
Timeline ─────────────────────────────────────▶
^ anchor event
trailing (default): [────window────] anchor
centered: [── anchor ──]
leading: anchor [────window────]
'trailing'(default) — "what's the average of what I've seen up to now."'centered'— "smoothed around now."'leading'— "what's the average of what's coming next."
cpu.rolling('5m', { cpu: 'avg' }, { alignment: 'centered' });
Opening events — where the window would extend beyond the series'
ends — emit undefined under trailing and centered; under leading,
trailing events emit undefined instead.
Warm-up gate (minSamples)
By default rolling emits a result for every source event, even when
the window holds only one or two samples. For stats whose stability
depends on having enough data — rolling stdev, percentiles, the
avg/sd pair behind a band chart — those early rows are noisy and
can false-flag downstream consumers (anomaly bands collapsing
around a tiny early sample). minSamples suppresses output until
the window contains at least N source events:
// Don't trust the rolling stats until at least 20 samples are in.
cpu.rolling(
'1m',
{ mean: { from: 'cpu', using: 'avg' }, sd: { from: 'cpu', using: 'stdev' } },
{ minSamples: 20 },
);
Rows below the threshold emit undefined for every reducer column;
the output schema and length don't change. Defaults to 0 (no
gate). Same option is available on
baseline() and outliers() and threads
through to their internal rolling pass.
Two output shapes
Pick the one that matches your output cadence:
// Per-event rolling: one output per source event, same key type.
cpu.rolling('5m', { cpu: 'avg' });
// Grid-anchored rolling: one output per Sequence point.
cpu.rolling(Sequence.every('1m'), '5m', { cpu: 'avg' });
The per-event form is the common one. Use the grid form when you want a fixed output cadence independent of where source events land — common for chart sampling.
Mapping shapes
Same two shapes as aggregate:
// Shorthand
cpu.rolling('5m', { cpu: 'avg' });
// Explicit { from, using, kind? } — rename, multi-reducer-per-column
cpu.rolling('5m', {
avg: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
peak: { from: 'cpu', using: 'max' },
});
The explicit form is how you get a band chart in one pass — avg
and sd from the same source column without two rolling traversals.
Anomaly detection ships a baseline helper
that's sugar over this exact pattern.
Custom reducers
Same contract as aggregate:
cpu.rolling('5m', {
cpu: (values) => {
const nums = values.filter((v): v is number => typeof v === 'number');
return nums.length === 0
? undefined
: nums.reduce((a, b) => a + b, 0) / nums.length;
},
});
Mix built-in and custom reducers in the same mapping.
Complexity
Most built-in reducers are O(1) per event via incremental
add/remove. min / max are O(1) amortized. Median and percentiles
are O(N) per event (sorted-array maintenance). Full table on
Reducer reference → Rolling window behavior.
tail(duration) — temporal slice
Often paired with rolling for a "current state" readout:
// Trailing 30s, then collapse to scalars.
const recent = series.tail('30s').reduce({
cpu: 'p95',
host: 'unique',
});
tail(duration) is the temporal counterpart to Array.slice(-n):
it keeps events whose begin() is strictly greater than
lastEvent.begin() - duration. Called with no argument it's the
identity (whole series). Composes with every method, not just
reduce — plot the last 30s, aggregate just the last hour, etc.
Live: LiveRollingAggregation
The streaming counterpart. Maintains a sliding window incrementally; each source event produces an output event with the rolling aggregate at that point.
import { LiveSeries } from 'pond-ts';
const live = new LiveSeries({ name: 'cpu', schema });
const rolling = live.rolling('5m', { cpu: 'avg' });
// rolling is a LiveRollingAggregation; satisfies LiveSource
rolling.on('event', (event) => {
console.log('rolling avg:', event.get('cpu'));
});
rolling.value(); // Record<string, ColumnValue | undefined>, e.g. { cpu: 0.42 }
// (each value is `undefined` until the window reaches minSamples)
Both mapping shapes work on live the same way
they work on batch — the AggregateOutputMap form is how you get
multiple stats from one source column in a single rolling pass:
const band = live.rolling('1m', {
mean: { from: 'cpu', using: 'avg' },
sd: { from: 'cpu', using: 'stdev' },
});
band.value(); // { mean, sd } in one pass — no second rolling deque
The same shape works on live.aggregate(seq, ...) and on the
synchronised partitioned form
(live.partitionBy(col).rolling(window, ..., { trigger: Trigger.clock(seq) })).
Custom (values) => ... reducers work on live rolling and live
aggregation (since v0.14.1) but pay an O(window-size) cost at each
snapshot() — the function re-runs over every value in the current
window each time the accumulator emits. For high-throughput streams
prefer a built-in ('avg', 'p95', 'samples', …) or compose
multiple built-ins via AggregateOutputMap aliases. See the
Custom reducers entry on the
reducer reference for the full trade-off.
The output buffer grows unbounded — compose with
window() to bound it.
Under ordering: 'reorder', a late event becomes a new output event
at its insertion point — LiveRollingAggregation does not
re-scan historical windows to include it. See
Live transforms → Late-event scope
for the full picture.
Reporting at a regular cadence: clock triggers
The live-side rolling() doesn't accept a Sequence directly the
way batch rolling(seq, window, mapping) does — emission shapes
differ (per-event by default for live, per-bucket for batch). When
you want to report the rolling state at a regular cadence (e.g.
push p95 to a backend every 30 s), set a Trigger.every(duration):
const rolling = live.rolling(
'1m',
{ latency: 'p95' },
{ trigger: Trigger.every('30s') },
);
For the full trigger reference (event / clock / count, semantics, sync partitioned rolling), see Triggering. The Telemetry reporting recipe is the end-to-end worked example.
Multi-window rolling
When a use case needs several trailing windows over the same
source — a 1-minute baseline of avg/stdev plus a 200-ms
current-tick window of raw samples for anomaly detection, say —
pass a record-of-mappings instead of a single (window, mapping)
pair:
const fused = live.rolling(
{
'1m': {
cpu_avg: { from: 'cpu', using: 'avg' },
cpu_sd: { from: 'cpu', using: 'stdev' },
},
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);
fused.on('event', (e) => {
// All four columns from both windows on a single event.
const samples = e.get('cpu_samples') as ReadonlyArray<number>;
const z = samples.map((s) => (s - e.get('cpu_avg')) / e.get('cpu_sd'));
// …
});
This is one rolling primitive that maintains all declared windows in one ingest pass over a single shared deque, then emits one merged event per trigger fire with all windows' columns concatenated into one record.
The naive way before this primitive existed — two separate
rolling() calls plus a per-(ts, key) join — works but pays
every per-event pipeline cost twice (and N times for N windows).
The keyed form does the per-event work once.
Properties
- One merged output stream. All columns from all windows arrive on a single event. Consumer code is one event handler, not a multi-stream join.
- Single trigger across all windows. Per-window cadence is
not supported — that's what the fusion saves. Users who need
different cadences per window fall back to two separate
rolling()calls. - Time-based windows only. Object keys are duration strings
(
'1m','200ms','5s'); count-based windows (live.rolling(100, ...)) stay on the single-window form and aren't mixable here. - Duplicate output column names across windows are rejected at construction.
- Single-window equivalence.
live.rolling('1m', m, opts)andlive.rolling({ '1m': m }, opts)are semantically identical — pinned by tests so the unification is incomplete the moment it diverges.
Per-window options
When one window needs different options from the rest (minSamples,
e.g.), the value form switches from a bare mapping to an
elaborated wrapper:
live.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': {
mapping: { cpu_samples: { from: 'cpu', using: 'samples' } },
minSamples: 5, // per-window override
},
},
{ trigger, minSamples: 2 }, // top-level default
);
Top-level options apply as the default across all windows;
per-window elaborated minSamples overrides for that window.
Partitioned variant
The same form composes with partitionBy(...) and works through
chained pipelines:
const fused = live
.partitionBy('host')
.fill({ cpu: 'hold' })
.rolling(
{
'1m': { cpu_avg: { from: 'cpu', using: 'avg' } },
'200ms': { cpu_samples: { from: 'cpu', using: 'samples' } },
},
{ trigger: Trigger.every('200ms') },
);
// One merged event per partition per boundary; partition column
// auto-injected at the front of the schema.
On the partitioned form, clock trigger is required — synced cross-partition emission needs a single shared boundary detector.
Performance
The architectural argument: per-event hops run once vs N times in
N separate rollings. Bench numbers from
packages/core/scripts/perf-fused-rolling.mjs:
| Windows | Separate (ms) | Multi-window (ms) | Wall delta | Heap delta |
|---|---|---|---|---|
| 2 | 152.91 | 102.91 | −33% | −33% |
| 3 | 186.63 | 79.89 | −57% | −54% |
| 4 | 245.42 | 107.51 | −56% | −58% |
| 5 | 279.79 | 118.90 | −58% | −66% |
100k events × 100 hosts at each N. Multi-window stays roughly constant; separate scales linearly. At N=5, multi-window is 2.4× faster and uses 34% of the heap. The win compounds — fused pipeline overhead is O(1) in the number of windows; per-window reducer state remains O(N) (which separate also pays).
When to use multi-window vs separate rollings
- Multi-window when several windows naturally emit together — baseline + current tick, day + hour summaries, etc. The shared ingest pass is the win, and the merged output is the ergonomic win.
- Separate rollings when the windows have different cadences (one fires every 30s, the other every 200ms), or when you only need one of the windows in a given consumer (you're not paying for the unused one's reducer state).
See Windowing → Multi-window rolling (live) for the conceptual framing.