Module 14: Window & Buffer
RxJS Mastery: Professional Course – Thinking in Streams
Module Version: 2.0
Last Updated: June 2026
Module Overview
This module teaches you two powerful time-based operators: buffer and window. These operators allow you to collect emissions over time or based on other Observables, enabling powerful patterns like batching analytics events, implementing rate limiting, and building time-aware data processing pipelines.
Estimated Total Time: 105–125 minutes
Difficulty: Intermediate to Advanced
Prerequisites: Modules 05, 13 completed
Learning Objectives
By the end of this module you will be able to:
- Understand the difference between
bufferandwindow - Use time-based, count-based, and notifier-based buffering
- Implement analytics batching and rate limiting
- Build time-aware reactive systems
- Choose the right operator for different use cases
Lesson 14.1: Buffer vs Window Philosophy
Estimated Time: 20 minutes
The Core Difference
buffer: Collects values into arrays — you getT[]per boundary.window: Collects values into Observables — you getObservable<T>per boundary (a higher-order Observable).
Visual Comparison
source: --1--2--3--4--5--6--|
bufferCount(3)
buffer output: --------[1,2,3]------[4,5,6]| (arrays)
windowCount(3)
window output: w1:(--1--2--3|) w2:(--4--5--6|) (each w is its own Observable)When to Use Which
bufferwhen you want the whole batch at once (send these 50 events together, render this chunk).windowwhen you want to apply operators per group — e.g. count, sum, or debounce within each window, then flatten the results.
// buffer: process the array
clicks$.pipe(bufferCount(10)).subscribe(tenClicks => send(tenClicks));
// window: reduce within each window, then flatten (Module 05)
import { count, mergeMap } from 'rxjs/operators';
clicks$.pipe(
windowTime(1000),
mergeMap(win$ => win$.pipe(count())) // clicks-per-second
).subscribe(perSecond => console.log(perSecond));Mental Model
buffer*hands you a completed array;window*hands you a live Observable you can pipe. Anythingbufferdoes,windowcan do followed bytoArray()— butwindowalso lets you stream-process each group.
The Boundary Variants
Both families take the same kinds of boundaries:
| Boundary | buffer | window |
|---|---|---|
| Time | bufferTime(ms) | windowTime(ms) |
| Count | bufferCount(n) | windowCount(n) |
| Another Observable | buffer(notifier$) | window(notifier$) |
| Open/close pairs | bufferToggle | windowToggle |
| Dynamic close | bufferWhen(fn) | windowWhen(fn) |
Common Mistake
Forgetting window is higher-order. Subscribing to a windowTime stream gives you Observables, not values — you must flatten (mergeMap/concatMap) each window. If you just want arrays, use buffer.
Quick Exercise
Use bufferCount(3) on of(1,2,3,4,5,6,7) and log the arrays. What happens to the leftover 7? (It emits as a partial [7] on complete.)
Key Takeaway: buffer* collects into arrays; window* collects into Observables for per-group stream processing — same boundary options, different output shape.
Lesson 14.2: Temporal Reasoning with Time-Based Windows
Estimated Time: 22 minutes
Fixed Time Windows
bufferTime(ms) emits an array every ms — perfect for steady batching:
source: --1-2-3-----4-5------6--|
bufferTime(t)
output: ------[1,2,3]----[4,5]----[6]|Note
bufferTimeemits even empty arrays on idle windows — usually filter them:filter(b => b.length > 0).
Time + Count (whichever first)
bufferTime takes an optional maxBufferSize — flush on time OR size, ideal for "send every 2s, but immediately if 50 pile up":
events$.pipe(
bufferTime(2000, null, 50), // timespan, creationInterval, maxBufferSize
filter(b => b.length > 0)
).subscribe(send);Sliding / Overlapping Windows
A second argument (bufferCreationInterval) creates overlapping windows — useful for moving averages:
readings$.pipe(bufferTime(5000, 1000)); // a 5s window, started every 1s (overlaps)Session Windows (Inactivity-Based)
Close a batch after a period of quiet — group bursts of activity. Use a debounced notifier as the boundary:
events: --a-b-c---------d-e-----|
close 300ms after the last event
batches: ----------[a,b,c]--------[d,e]|buffer(events$.pipe(debounceTime(300))) // flush when the source goes quiet for 300msRate Limiting via Windows
windowTime/bufferTime underpin rate limiting: "at most N operations per second" → window per second, take N per window:
requests$.pipe(
windowTime(1000),
mergeMap(win$ => win$.pipe(take(5))) // max 5 per second
).subscribe(process);Common Mistake
Not handling empty bufferTime windows. On an idle source, bufferTime keeps emitting []. Filter them, or you'll "send" empty batches every interval.
Quick Exercise
Build a session-window batcher: buffer(source$.pipe(debounceTime(500))) and confirm a batch only emits after the source is quiet for 500ms.
Key Takeaway: Time windows enable batching (bufferTime), time-or-count flush (maxBufferSize), overlapping moving windows, session windows (debounced notifier), and rate limiting (window + take).
Lesson 14.3: Batching Analytics Data Efficiently
Estimated Time: 20 minutes
Why Batch?
Sending one network request per event is wasteful — connection overhead, rate limits, battery. Batching collects many events and sends them in one request, cutting network calls dramatically.
50 events ──bufferTime(2000)──► ~1 request every 2s (instead of 50 requests)A Robust Analytics Batcher
Flush on time or size, drop empties, and never lose the tail:
const flush$ = analyticsEvents$.pipe(
bufferTime(5000, null, 20), // every 5s OR when 20 events pile up
filter(batch => batch.length > 0)
);
flush$.pipe(
concatMap(batch => http.post('/analytics', batch)) // ordered, one request at a time
).subscribe();Don't Lose Events on Unload
A pure timer can lose the final partial batch when the user leaves. Flush on beforeunload too (often via navigator.sendBeacon), and consider bufferToggle/manual flush for "send now" moments.
Idempotency & Retries
Batched sends should be idempotent (include a batch id) so a retry (Module 08) doesn't double-count. Pair batching with backoff retry for resilience.
Performance Considerations
- Network calls saved ≈
eventsBatched / batchesSent— the whole point; track it. - Memory — a buffer holds events until flush. Cap with
maxBufferSizeso a burst can't grow it unbounded. - Latency vs efficiency — bigger windows = fewer requests but staler data. Tune the timespan to your needs.
Common Mistake
Unbounded buffers. A pure bufferTime with no maxBufferSize can accumulate a huge array during a burst. Always cap size for high-volume events.
Quick Exercise
Write an analytics batcher that flushes every 3s or at 10 events, drops empty batches, and posts with concatMap. Add a batch id to each payload.
Key Takeaway: Batch analytics with bufferTime(span, null, maxSize) + non-empty filter + concatMap send; make sends idempotent, flush on unload, and cap buffer size.
Lesson 14.4: Combining Window/Buffer with Other Operators
Estimated Time: 22 minutes
Per-Window Aggregation (window + reduce)
The classic window use: compute a statistic per time window.
import { mergeMap, reduce } from 'rxjs/operators';
clicks$.pipe(
windowTime(1000),
mergeMap(win$ => win$.pipe(
reduce((acc, _) => acc + 1, 0) // count per window
))
).subscribe(perSecond => render(perSecond));Buffer + map/filter
Transform or filter batches before sending:
events$.pipe(
bufferTime(2000),
filter(b => b.length > 0),
map(b => ({ count: b.length, types: summarize(b), batch: b }))
).subscribe(send);Dynamic Windows with bufferToggle
Open and close buffers on signals — e.g. record only between "start" and "stop":
source$.pipe(
bufferToggle(starts$, () => stops$) // collect from each start until its stop
).subscribe(recorded);Combining with Backpressure (Module 13)
Buffering is a backpressure tool — it converts a high-frequency stream into a low-frequency stream of batches, which a slow consumer can handle. Combine with concatMap (ordered, one batch at a time) so the consumer is never overwhelmed.
Common Mistake
Flattening windows with the wrong operator. Use mergeMap for independent windows, concatMap when window results must stay ordered. switchMap would cancel the previous window mid-aggregation — almost never what you want for windowing.
Quick Exercise
Compute a moving "events per 2 seconds" metric with windowTime(2000) + mergeMap(w => w.pipe(count())), and render each value.
Key Takeaway: window + reduce/count aggregates per group; buffer + map/filter shapes batches; use bufferToggle for signal-driven windows and concatMap (not switchMap) to flatten in order.
Lesson 14.5: Project Workshop – Analytics Batching Dashboard
Estimated Time: 30 minutes
Project Goal
Build an analytics batching dashboard that collects events and flushes them efficiently, with a selectable strategy:
- Generate events (auto-fire + a manual "Track Event" button)
- Batch with a selectable strategy: time (
bufferTime), count (bufferCount), or smart (time or count) - "Send" each batch (logged), with empty batches filtered out
- Live metrics: events tracked, batches sent, avg batch size, and network calls saved
- A batch log showing each flush (size + event-type breakdown)
Why This Project Matters
This is real analytics infrastructure in miniature. You will see batching collapse dozens of events into a handful of "requests," and the "calls saved" metric quantifies the win — the reason every analytics SDK batches.
Step-by-Step Build (Video-Friendly)
- Setup — Single HTML file with Tailwind + RxJS 7 from CDN; controls, metrics, and a batch log.
- Event source —
mergea manualSubjectwith a toggleable auto-feed;share()it. - Strategy —
bufferTime/bufferCount/bufferTime(span, null, max), filtered to non-empty. - Send — pass each batch through a fake async endpoint with
concatMap; update metrics when it completes. - Switch —
switchMapover the strategy selector to swap batchers live.
Complete Working Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Analytics Batching • RxJS buffer</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/rxjs@7/dist/bundles/rxjs.umd.min.js"></script>
</head>
<body class="bg-zinc-950 text-zinc-200">
<div class="max-w-2xl mx-auto p-8">
<h1 class="text-3xl font-semibold tracking-tight mb-1">Analytics Batching</h1>
<p class="text-zinc-400 mb-6">Collect events, flush in batches with <code class="text-emerald-400">buffer</code></p>
<!-- Controls -->
<div class="flex flex-wrap items-center gap-4 mb-6">
<button id="track" class="bg-emerald-600 hover:bg-emerald-500 text-white font-medium py-2.5 px-5 rounded-xl">Track Event</button>
<label class="flex items-center gap-2 text-sm text-zinc-400"><input id="auto" type="checkbox"> Auto-fire</label>
<label class="flex items-center gap-2 text-sm text-zinc-400 ml-auto">
Strategy
<select id="strategy" class="bg-zinc-900 border border-zinc-700 rounded-lg px-3 py-2 text-zinc-200">
<option value="time">bufferTime(3000)</option>
<option value="count">bufferCount(5)</option>
<option value="smart" selected>smart: 3s or 5 events</option>
</select>
</label>
</div>
<!-- Metrics -->
<div class="grid grid-cols-4 gap-3 mb-6 text-center">
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3"><div class="text-[10px] uppercase text-zinc-500">Events</div><div id="mEvents" class="text-2xl font-semibold">0</div></div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3"><div class="text-[10px] uppercase text-zinc-500">Batches</div><div id="mBatches" class="text-2xl font-semibold text-sky-400">0</div></div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3"><div class="text-[10px] uppercase text-zinc-500">Avg size</div><div id="mAvg" class="text-2xl font-semibold text-amber-400">0</div></div>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl p-3"><div class="text-[10px] uppercase text-zinc-500">Calls saved</div><div id="mSaved" class="text-2xl font-semibold text-emerald-400">0</div></div>
</div>
<div class="text-xs uppercase tracking-[2px] text-zinc-500 mb-2">Sent Batches</div>
<div id="log" class="h-56 overflow-auto space-y-2 font-mono text-xs"></div>
</div>
<script>
const { interval, merge, Subject, EMPTY, of } = rxjs;
const { map, bufferTime, bufferCount, filter, share, switchMap, startWith, concatMap, delay, tap } = rxjs.operators;
const TYPES = ['click', 'view', 'scroll', 'hover', 'purchase'];
const randomEvent = () => ({ type: TYPES[Math.floor(Math.random() * TYPES.length)], ts: Date.now() });
let eventsTracked = 0, batchesSent = 0, totalBatched = 0;
// --- Event source: manual + toggleable auto, shared ---
const manual$ = new Subject();
const autoToggle$ = new Subject();
const auto$ = autoToggle$.pipe(
startWith(false),
switchMap(on => on ? interval(600) : EMPTY)
);
const events$ = merge(manual$, auto$).pipe(map(() => randomEvent()), share());
// Count every event (separate cheap subscriber)
events$.subscribe(() => { eventsTracked++; renderMetrics(); });
// --- Batching strategy ---
function applyStrategy(strat) {
switch (strat) {
case 'time': return events$.pipe(bufferTime(3000));
case 'count': return events$.pipe(bufferCount(5));
case 'smart': return events$.pipe(bufferTime(3000, null, 5)); // time OR 5 events
}
}
const strategy$ = new Subject();
const postBatch = batch => of(batch).pipe(delay(250));
strategy$.pipe(
startWith('smart'),
switchMap(strat => applyStrategy(strat).pipe(filter(b => b.length > 0))),
concatMap(batch => postBatch(batch).pipe(tap(sendBatch)))
).subscribe();
// --- "Send" a batch (logged) ---
const logEl = document.getElementById('log');
function sendBatch(batch) {
batchesSent++;
totalBatched += batch.length;
const counts = batch.reduce((m, e) => (m[e.type] = (m[e.type] || 0) + 1, m), {});
const summary = Object.entries(counts).map(([t, n]) => `${t}×${n}`).join(' ');
const row = document.createElement('div');
row.className = 'bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2';
row.innerHTML = `<span class="text-sky-400">POST /analytics</span>
<span class="text-zinc-500">(${batch.length} events)</span>
<div class="text-zinc-400 mt-0.5">${summary}</div>`;
logEl.prepend(row);
renderMetrics();
}
function renderMetrics() {
document.getElementById('mEvents').textContent = eventsTracked;
document.getElementById('mBatches').textContent = batchesSent;
document.getElementById('mAvg').textContent = batchesSent ? (totalBatched / batchesSent).toFixed(1) : '0';
document.getElementById('mSaved').textContent = Math.max(0, totalBatched - batchesSent);
}
// --- Wiring ---
document.getElementById('track').addEventListener('click', () => manual$.next());
document.getElementById('auto').addEventListener('change', e => autoToggle$.next(e.target.checked));
document.getElementById('strategy').addEventListener('change', e => strategy$.next(e.target.value));
</script>
</body>
</html>Key Lessons from This Project
buffer*turns N events into one batch — the "calls saved" metric (totalBatched − batchesSent) is the payoff.- Smart flush = time OR count —
bufferTime(span, null, maxSize)flushes whichever comes first, balancing latency and efficiency. - Always drop empty batches —
bufferTimeemits[]on idle windows;filter(b => b.length > 0)keeps you from "sending nothing." concatMapsends batches in order — a new batch waits until the previous fake request completes.switchMapswaps strategies live — the sharedevents$keeps counting while the batcher re-subscribes.
Stretch Goals (Recommended Practice)
- Add a session window strategy:
buffer(events$.pipe(debounceTime(800)))— flush after the user goes idle. - Send batches with
concatMapto a fake async endpoint (withdelay) and show in-flight state. - Add a
beforeunloadflush so the final partial batch isn't lost. - Add a window-based "events per 2s" live chart (
windowTime+count).
Deliverable
An analytics batcher that collects events, flushes via a selectable strategy (time / count / smart), drops empties, and reports events, batches, average size, and network calls saved.
Key Takeaway: You built an analytics batcher that collapses many events into few requests with buffer — quantifying the savings and balancing latency against efficiency, the core of every analytics SDK.
End-of-Module Quiz
5 Multiple Choice Questions
What is the fundamental difference between
bufferandwindow?- A)
bufferemits arrays of values;windowemits Observables of values - B)
bufferis faster thanwindow - C)
windowonly works with time;bufferonly with count - D) There is no difference
- A)
bufferTime(2000, null, 50)flushes the buffer when…- A) exactly 2000 events arrive
- B) only after 2000ms, ignoring size
- C) 2000ms elapse OR 50 events accumulate, whichever comes first
- D) the source completes
Why must you usually
filter(b => b.length > 0)afterbufferTime?- A) To sort the batch
- B)
bufferTimeerrors on empty windows - C) To remove duplicate events
- D) Because
bufferTimeemits empty arrays on idle windows
To compute "clicks per second", which combination is idiomatic?
- A)
bufferCount(1) - B)
debounceTime(1000) - C)
windowTime(1000)+mergeMap(w => w.pipe(count())) - D)
switchMapover each click
- A)
In the batching dashboard, what does "calls saved" (
totalBatched − batchesSent) represent?- A) Memory freed
- B) The number of network requests avoided by batching
- C) The number of dropped events
- D) The latency in milliseconds
Correct Answers: 1-A, 2-C, 3-D, 4-C, 5-B
Explanations:
- Q1:
buffer*collects values into arrays;window*collects them into Observables you can pipe per-group. - Q2: The third argument is
maxBufferSize, so the buffer flushes on the timespan or when it reaches that size — whichever happens first. - Q3:
bufferTimeemits an array every interval even when empty; filter those out so you don't process/send empty batches. - Q4: Per-window aggregation:
windowTime(1000)then flatten each window withcount()viamergeMap. - Q5: Each batch is one request for many events, so
totalBatched − batchesSentis the number of requests batching avoided.
Module Summary & Next Steps
You can now reason about time-grouped streams:
buffer*(arrays) vswindow*(Observables), and all the boundary variants (time, count, notifier, toggle, when)- Temporal windows: fixed, time-or-count, overlapping/sliding, session (inactivity), and rate limiting
- Efficient analytics batching with non-empty filtering,
maxBufferSizecaps, idempotent ordered sends, and unload flushing - Combining
windowwithreduce/countfor per-window aggregation, andbufferTogglefor signal-driven windows
Next Module: Module 15 – Time Windowing (tumbling vs sliding windows, moving averages and real-time aggregations, and windowing for rate limiting)
Recommended Practice: Add a session-window strategy and a windowTime "events per 2s" live metric to the batching dashboard.
Improved Module 14 v2.0 – Part of the RxJS Mastery Professional Course