A build session — compute on write, read in O(1), and count a million customers with a bitwise AND
MULTI/EXEC, and defend every guard against absent fieldsMGET, ZREVRANGE … WITHSCORES, BITCOUNT and HGETALLBITOP AND plus BITCOUNT and justify the memory cost at a million customers| Time | Segment | Activity |
|---|---|---|
| 0–6 min | Recap & Build Brief | What we are shipping today and how it differs from the Atlas Charts dashboard |
| 6–16 min | Architecture | Event source → writer → read API → browser; where each piece runs and why |
| 16–28 min | The Design Shift | Compute on write vs compute on read; the cost model of each |
| 28–40 min | Key Schema Design | Eight keys, five types; naming, TTLs, and handling absent fields |
| 40–58 min | Build the Writer | MULTI/EXEC, pipelining a batch, backfilling from bdma.rides |
| 58–72 min | Build the Reader | Express handler, the dashboard payload, a polling browser view |
| 72–82 min | Retention & Comparison | BITOP AND cohort; side-by-side against Atlas Charts |
| 82–90 min | Limits & Wrap-Up | What a new question costs; Lecture 19 project-presentation brief |
We are building the thing a ride-hailing operations room actually stares at: total bookings, revenue so far today, the status mix, revenue by vehicle type, the busiest pickup points, the hourly curve, the payment split, and how many distinct customers have booked today — all refreshing every two seconds.
The model is the Redis Labs reference project basic-analytics-dashboard-redis-bitmaps-nodejs, rebuilt against our own ride-booking domain so it connects to everything you have already modelled.
Event source. Every completed or failed booking is an event. In production this is the booking service emitting to a queue. In our lab it is a replayer that streams data/uber_bookings.json — the 15,000 documents generated by data/generate_uber_dataset.js — at a few per second.
Writer. A Node.js function that receives one booking and updates every Redis structure that booking affects, in a single atomic transaction. This is where all the analytical work happens — once per event, never again.
Read API. A small Express service that reads the already-computed aggregates and returns one JSON payload. It performs no grouping, no sorting, no scanning — every value it returns already exists.
Browser. A static page that polls /api/dashboard every two seconds and repaints. Because the read is cheap, polling at this rate is reasonable — which is exactly what makes the dashboard feel live.
| Stage | Runs | Does | Cost per booking / per view |
|---|---|---|---|
| 1. Event source | Once per booking | Emits the booking document | — |
| 2. Writer | Once per booking | ~8 Redis commands in one MULTI | Sub-millisecond, paid once |
| 3. Read API | Once per dashboard poll | 5 reads, all O(1) or O(log N) | Sub-millisecond, independent of data volume |
| 4. Browser | Every 2 seconds | fetch + repaint | One HTTP request |
Note where MongoDB sits in this picture: still underneath, still holding all 15,000 booking documents permanently. Redis never replaces it. If a Redis key is lost or a new metric is added, stage 2 is re-run over the rides collection and the aggregates are rebuilt. The system of record is what makes the speed layer disposable — and therefore safe.
This is the central idea of the lecture, and it is a direct reversal of what you did in Module 3.
// MongoDB — the work happens when somebody asks
db.rides.aggregate([
{ $match: { status: "Completed" } },
{ $group: { _id: "$vehicle_type", revenue: { $sum: "$booking_value" } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
])
Beautiful, flexible, and re-executed in full every single time the tile renders. The cost is proportional to the number of matching documents, so it grows as the business grows — precisely backwards from what you want.
// Redis — the work happens when the booking arrives
ZINCRBY dash:vehicle:revenue 412.50 "Go Sedan" // once, on write
// ... and the dashboard read, forever after:
ZREVRANGE dash:vehicle:revenue 0 4 WITHSCORES // O(log N + 5)
The aggregate is maintained incrementally. A read is a slice of an already-sorted structure, and its cost does not depend on whether you have processed 15,000 bookings or 15,000,000.
| Compute on read (MongoDB) | Compute on write (Redis) | |
|---|---|---|
| When the work happens | At query time, every time | Once per event, at ingest |
| Read cost | Grows with data volume | Constant — O(1) or O(log N) |
| Write cost | One insert | One insert plus ~8 counter updates |
| New question | Write a new pipeline. Answer available immediately, over all history | Change the writer, redeploy, backfill. Nothing before the change exists |
| Total work at 1M reads of 1 metric | 1,000,000 aggregations | 1 increment + 1,000,000 cheap lookups |
| Right when | Questions are varied, unpredictable, infrequent, exploratory | Questions are known, fixed, and asked constantly |
The decision rule, in one line: if the read-to-write ratio is high and the question is stable, pre-compute. If the read-to-write ratio is low or the questions keep changing, compute on demand. An operations dashboard refreshed by forty people all day is the first case. An analyst exploring last quarter's cancellations is the second. Most real companies run both, over the same data.
The hidden cost, stated plainly: pre-computation moves work from the read path to the write path, and it moves risk with it. A bug in the writer corrupts a counter permanently — there is no re-query that fixes it. This is why the system of record matters: your repair procedure is "delete the key and replay bdma.rides", and you should have tested that procedure before you need it.
Redis has no catalogue, so this table is the schema. Write it before you write code; keep it in the repository next to the writer.
| Key pattern | Type | Purpose | Written by |
|---|---|---|---|
dash:total:bookings | String | All bookings ever ingested | INCR |
dash:status:{status} | String | One counter per outcome — completed, cancelled-by-driver, no-driver-found, cancelled-by-customer, incomplete | INCR |
dash:revenue:{YYYY-MM-DD} | String | Running completed revenue for a date | INCRBYFLOAT |
dash:vehicle:revenue | Sorted Set | Revenue per vehicle type, permanently ranked | ZINCRBY |
dash:pickup:top | Sorted Set | Booking count per pickup location, permanently ranked | ZINCRBY |
dash:hour:{H} | String | 24 hourly buckets, H = 0–23 | INCR |
dash:customers:{YYYY-MM-DD} | Bitmap | One bit per customer — did they book that day? | SETBIT at the numeric offset |
dash:payment | Hash | Booking count per payment method | HINCRBY |
"Cancelled by Driver" becomes dash:status:cancelled-by-driver. Spaces in key names are legal but a constant nuisance in redis-cli, in logs, and in shell scripts. Normalise once, in one helper, and never think about it again.
Our IDs are CID482910. Strip the three-character prefix and the remainder is a dense integer offset, which is exactly what a bitmap needs. This is a gift of the dataset design — a real system with UUID customer keys would need a separate customer:offset Hash and an INCR-based allocator.
Date-scoped keys (dash:customers:*, dash:revenue:*) get 90 days. Cumulative keys (dash:total:bookings, the two ZSETs, dash:payment) get none — they are the dashboard's permanent state. Combined with maxmemory-policy volatile-lru from Lecture 17, that means pressure evicts old daily keys first and never touches the live aggregates.
The field-absence trap — make this a teaching point. In bdma.rides, booking_value, ride_distance, ratings and payment_method are absent on cancelled and no-driver-found bookings; Incomplete documents carry booking_value and ride_distance but no ratings. That is 5,657 of 15,000 documents — 37.71% — where a naive INCRBYFLOAT key undefined throws, or worse, silently writes NaN and poisons the counter for good. Every optional field needs a guard, and every guard needs a reason you can state.
mkdir bdma-redis-dashboard && cd bdma-redis-dashboard
npm init -y
npm pkg set type=module # enables import syntax and top-level await
npm install redis express
docker start bdma-redis # the container from Lecture 17
keys.js — The Schema as CodeNever build a key string inline. One module owns every key pattern, so a rename is one edit and a typo is impossible.
// keys.js
export const slug = (s) => s.toLowerCase().replace(/\s+/g, '-');
// "CID482910" -> 482910. Dense, stable, and exactly what SETBIT needs.
export const customerOffset = (id) => Number.parseInt(id.slice(3), 10);
export const K = {
totalBookings: 'dash:total:bookings',
vehicleRevenue: 'dash:vehicle:revenue',
pickupTop: 'dash:pickup:top',
payment: 'dash:payment',
status: (s) => `dash:status:${slug(s)}`,
revenue: (date) => `dash:revenue:${date}`,
hour: (h) => `dash:hour:${h}`,
customers: (date) => `dash:customers:${date}`,
};
writer.js — One Booking, One Transaction// writer.js
import { createClient } from 'redis';
import { K, customerOffset } from './keys.js';
export const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379'
});
redis.on('error', (err) => console.error('[redis]', err.message));
await redis.connect();
const NINETY_DAYS = 60 * 60 * 24 * 90;
// Queues one booking's updates onto an existing MULTI. Pure: no awaits,
// so the same function serves a single write and a batched pipeline.
function queueBooking(tx, b) {
const day = b.date; // "2024-03-14"
const hour = new Date(b.booked_at).getUTCHours(); // 18
// --- always present on every document ---
tx.incr(K.totalBookings);
tx.incr(K.status(b.status));
tx.incr(K.hour(hour));
tx.zIncrBy(K.pickupTop, 1, b.route.pickup);
tx.setBit(K.customers(day), customerOffset(b.customer_id), 1);
// NX = only set the TTL if the key has none, so a day's window is not
// pushed forward by every booking that lands in it.
tx.expire(K.customers(day), NINETY_DAYS, 'NX');
tx.expire(K.revenue(day), NINETY_DAYS, 'NX');
// --- absent on Cancelled / No Driver Found (5,657 of 15,000 docs) ---
if (typeof b.booking_value === 'number') {
tx.incrByFloat(K.revenue(day), b.booking_value);
tx.zIncrBy(K.vehicleRevenue, b.booking_value, b.vehicle_type);
}
// --- absent unless money actually changed hands ---
if (b.payment_method) {
tx.hIncrBy(K.payment, b.payment_method, 1);
}
}
export async function recordBooking(booking) {
const tx = redis.multi();
queueBooking(tx, booking);
return tx.exec(); // all ~8 commands, atomically
}
export async function recordBatch(bookings) {
const tx = redis.multi();
for (const b of bookings) queueBooking(tx, b);
return tx.exec(); // one round trip for the whole batch
}
MULTI/EXECMULTI opens a transaction; commands are queued on the server and EXEC runs the whole block without any other client's command interleaving. Two guarantees follow.
Consistency between keys. Without it, a dashboard poll landing mid-write could read a total of 9,344 against a status breakdown still summing to 9,343. Small, embarrassing, and impossible to reproduce. With MULTI, the eight updates are visible all at once or not at all.
Throughput. Eight separate awaited commands are eight network round trips. Queued on one MULTI they are one. On a 0.5 ms round trip that is 4 ms versus 0.5 ms per booking — the difference between ingesting 250 and 2,000 bookings a second.
Be precise about what Redis transactions are not. There is no rollback. If one command inside EXEC fails at runtime — say you INCR a key holding a Hash — the remaining commands still run and the earlier ones stand. Redis guarantees isolation and ordering, not atomic undo. You get correctness from writing correct commands and guarding your inputs, not from a safety net.
replay.js — Backfilling the Course DatasetLoad the 15,000 generated bookings to populate the dashboard, then stream the tail slowly so the page visibly moves during class.
// replay.js
import { readFile } from 'node:fs/promises';
import { recordBatch, recordBooking, redis } from './writer.js';
const bookings = JSON.parse(
await readFile(new URL('../data/uber_bookings.json', import.meta.url), 'utf8')
);
bookings.sort((a, b) => a.booked_at.localeCompare(b.booked_at));
// 1. Backfill everything except the last 200, in batches of 500.
const history = bookings.slice(0, -200);
for (let i = 0; i < history.length; i += 500) {
await recordBatch(history.slice(i, i + 500));
}
console.log(`backfilled ${history.length} bookings`);
// 2. Dribble the remainder in so the dashboard moves while you talk.
for (const booking of bookings.slice(-200)) {
await recordBooking(booking);
await new Promise((r) => setTimeout(r, 400));
}
await redis.quit();
Backfilling from the system of record instead. Swap the file read for a MongoDB cursor and the same writer serves both paths — which is the point of keeping the ingest logic in one pure function:
import { MongoClient } from 'mongodb';
const mongo = await MongoClient.connect(process.env.MONGO_URL);
const cursor = mongo.db('bdma').collection('rides')
.find({}, { sort: { booked_at: 1 } });
let batch = [];
for await (const booking of cursor) {
batch.push(booking);
if (batch.length === 500) { await recordBatch(batch); batch = []; }
}
if (batch.length) await recordBatch(batch);
After the backfill, check your work against the verified figures from Module 3:
GET dash:total:bookings # -> 15000
GET dash:status:completed # -> 9343 (62.29%)
GET dash:status:cancelled-by-driver # -> 2698 (17.99%)
GET dash:status:no-driver-found # -> 1036 (6.91%)
GET dash:status:cancelled-by-customer # -> 1022 (6.81%)
GET dash:status:incomplete # -> 901 (6.01%)
ZREVRANGE dash:vehicle:revenue 0 2 WITHSCORES
# Premier Sedan 536855 · Go Sedan 491473 · Uber XL 431927
HGETALL dash:payment # UPI 45.03%, Cash 25.31% ...
GET dash:hour:18 # -> 1373, the busiest hour
This reconciliation step is not optional. A pre-computed counter has no audit trail — the only way to know it is right is to check it against the system of record. Build that check into the project now; in production it becomes a nightly job that recomputes the aggregates from Mongo and alarms on any divergence.
Note what this handler does not contain: no grouping, no sorting, no filtering, no scanning. Every number it returns already exists in memory. Its only job is to fetch and shape.
// api.js
import express from 'express';
import { redis } from './writer.js';
import { K } from './keys.js';
const app = express();
const STATUSES = [
'Completed', 'Cancelled by Driver', 'No Driver Found',
'Cancelled by Customer', 'Incomplete'
];
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const num = (v) => Number(v ?? 0);
app.get('/api/dashboard', async (req, res, next) => {
try {
const day = req.query.date ?? '2024-03-14';
const [headline, statusCounts, hourCounts, vehicles, pickups, payment, actives] =
await Promise.all([
redis.mGet([K.totalBookings, K.revenue(day)]),
redis.mGet(STATUSES.map((s) => K.status(s))),
redis.mGet(HOURS.map((h) => K.hour(h))),
redis.zRangeWithScores(K.vehicleRevenue, 0, 4, { REV: true }),
redis.zRangeWithScores(K.pickupTop, 0, 4, { REV: true }),
redis.hGetAll(K.payment),
redis.bitCount(K.customers(day)),
]);
res.json({
date: day,
total_bookings: num(headline[0]),
revenue_today: Number(num(headline[1]).toFixed(2)),
active_customers: actives,
status: Object.fromEntries(
STATUSES.map((s, i) => [s, num(statusCounts[i])])
),
hourly: HOURS.map((h) => ({ hour: h, bookings: num(hourCounts[h]) })),
vehicle_revenue: vehicles.map(({ value, score }) =>
({ vehicle_type: value, revenue: score })),
top_pickups: pickups.map(({ value, score }) =>
({ pickup: value, bookings: score })),
payment_mix: payment,
generated_at: new Date().toISOString(),
});
} catch (err) { next(err); }
});
app.use(express.static('public'));
app.listen(3000, () => console.log('dashboard on http://localhost:3000'));
| Read | Command | Complexity | Returns |
|---|---|---|---|
| Headline counters | MGET | O(n) in keys requested, not data | Total bookings, today's revenue |
| Status mix | MGET × 5 | O(5) | The five outcome counters |
| Hourly curve | MGET × 24 | O(24) | The full day's shape in one round trip |
| Top vehicles / pickups | ZRANGE … REV WITHSCORES | O(log N + 5) | Already ranked, with scores |
| Payment mix | HGETALL | O(5) | Every method and its count |
| Distinct customers today | BITCOUNT | O(bitmap bytes) — 125 KB at 1M users | Exact daily actives |
Why Promise.all and not six sequential awaits? Six awaits are six serialised round trips. Issued together, node-redis writes them to the socket back to back and the server — executing serially, remember — answers them in order without the client waiting in between. Same number of commands, roughly one round trip of latency instead of six.
Every number in that payload is a count. The moment the business asks for a rate — "failure rate by hour", the chart you built in Module 3 — you hit the first real limit of compute-on-write: a ratio needs a numerator and a denominator, and Redis will only ever hand you the counters you decided to keep. dash:hour:{H} is the denominator. The numerator does not exist yet, so you add one counter and one line to the writer:
// keys.js — one addition
hourFail: (h) => `dash:hourfail:${h}`,
// writer.js — inside queueBooking, alongside tx.incr(K.hour(h))
if (b.status !== 'Completed') tx.incr(K.hourFail(h));
Then re-run replay.js, because a counter you add today starts at zero and knows nothing about the 15,000 bookings already in Mongo. That is the compute-on-write bargain in miniature, and Section 7 returns to it.
// api.js — the rate is computed on read, from two O(1) counters
const [hourTotals, hourFails] = await Promise.all([
redis.mGet(HOURS.map((h) => K.hour(h))),
redis.mGet(HOURS.map((h) => K.hourFail(h))),
]);
const MIN_BOOKINGS = 1000; // the gate — see below
const hourly = HOURS.map((h) => {
const bookings = num(hourTotals[h]);
const failed = num(hourFails[h]);
return {
hour: h,
bookings,
failed,
failure_rate: bookings ? Number((100 * failed / bookings).toFixed(2)) : null,
ranked: bookings >= MIN_BOOKINGS, // only these are comparable
};
});
// Worst hour — but only among hours that carry volume
const worst = hourly
.filter((x) => x.ranked)
.sort((a, b) => b.failure_rate - a.failure_rate)[0];
That filter is the whole lesson. Without it, the dashboard ranks by rate alone and reports the wrong hour with total confidence:
| Hour | Bookings | Failure rate | Failed bookings | Comparable? |
|---|---|---|---|---|
| 03:00 | 46 | 43.48% | ≈ 20 | No — sample too small |
| 07:00 | 637 | 41.60% | ≈ 265 | No |
| 16:00 | 764 | 40.45% | ≈ 309 | No |
| 17:00 | 1,062 | 39.92% | ≈ 424 | Yes — worst of these |
| 18:00 | 1,373 | 37.14% | ≈ 510 | Yes — busiest hour |
Read that table before you believe any ranking. 03:00 has the highest failure rate in the dataset, and it is worthless: 43.48% of 46 bookings is about twenty failures across an entire year. One bad night moves that number four percentage points. Ranked by rate alone, your dashboard points operations at a dead hour.
The honest finding is narrower and far more useful: the worst failure rate among the hours that actually carry volume is 17:00 at 39.92% — higher than the 18:00 peak's 37.14%, even though 18:00 takes 1,373 bookings to 17:00's 1,062. That is a real operational signal: the hour just before peak is failing at a higher rate than peak itself, which points at supply positioning ahead of the evening rush rather than at capacity during it.
Note the third column too. 18:00 produces more failed bookings in absolute terms (≈ 510 against ≈ 424) even at the lower rate. Rate tells you where the process is weakest; volume tells you where the money is. A dashboard that shows only one of them will be misread — which is exactly why the tile should carry the denominator next to the percentage.
The same guard in the MongoDB pipeline. You already know this one from Lecture 14: after the $group that computes bookings and failures per hour, insert { $match: { bookings: { $gte: 1000 } } } before the $sort. One stage, and the small-sample hours stop competing. The difference is where the guard lives: in Mongo it sits in the query, so every consumer of that pipeline inherits it; in Redis it sits in whichever piece of application code happens to read the counters, so a second service reading the same keys can re-introduce the bug. Pre-computed metrics do not carry their own caveats — you have to.
A static page in public/index.html. Poll, repaint, nothing clever.
<div id="total"></div>
<div id="revenue"></div>
<div id="actives"></div>
<ol id="vehicles"></ol>
<ol id="pickups"></ol>
<script>
const inr = (n) => '₹' + Number(n).toLocaleString('en-IN',
{ maximumFractionDigits: 0 });
async function refresh() {
const res = await fetch('/api/dashboard?date=2024-03-14');
if (!res.ok) return; // keep the last good paint
const d = await res.json();
total.textContent = d.total_bookings.toLocaleString('en-IN');
revenue.textContent = inr(d.revenue_today);
actives.textContent = d.active_customers.toLocaleString('en-IN');
vehicles.innerHTML = d.vehicle_revenue
.map((v) => `<li>${v.vehicle_type} — ${inr(v.revenue)}</li>`).join('');
pickups.innerHTML = d.top_pickups
.map((p) => `<li>${p.pickup} — ${p.bookings}</li>`).join('');
}
refresh();
setInterval(refresh, 2000);
</script>
# Two terminals
node api.js # terminal 1 — http://localhost:3000
node replay.js # terminal 2 — watch the numbers climb
curl -s localhost:3000/api/dashboard?date=2024-03-14 | head -c 400
Polling versus pushing. A two-second poll is the right call here: it is four lines of code, it survives a dropped connection without any reconnect logic, and the read costs almost nothing. Server-Sent Events or WebSockets become worthwhile when updates are genuinely sub-second or when thousands of clients would make polling expensive. Choose the simplest mechanism the latency requirement allows — the engineering fashion is not the requirement.
Everything so far could have been done with ordinary counters. This is the part that could not.
The business question: of the customers who booked on 13 March, how many came back on the 14th? Day-over-day retention is one of the few metrics that genuinely predicts the health of a marketplace, and it is expensive to compute the obvious way — a set of yesterday's customers, a set of today's, and an intersection over millions of identifiers.
# Two commands. That is the entire computation.
BITOP AND dash:retained:2024-03-13:2024-03-14 \
dash:customers:2024-03-13 dash:customers:2024-03-14
BITCOUNT dash:retained:2024-03-13:2024-03-14
// api.js — exposed as an endpoint
app.get('/api/retention', async (req, res, next) => {
try {
const { from, to } = req.query; // ?from=2024-03-13&to=2024-03-14
const dest = `dash:retained:${from}:${to}`;
await redis.bitOp('AND', dest, [K.customers(from), K.customers(to)]);
await redis.expire(dest, 300); // a derived scratch key
const [returning, dayOne, dayTwo] = await Promise.all([
redis.bitCount(dest),
redis.bitCount(K.customers(from)),
redis.bitCount(K.customers(to)),
]);
res.json({
from, to,
day_one_actives: dayOne,
day_two_actives: dayTwo,
returning,
retention_pct: dayOne ? Number((returning / dayOne * 100).toFixed(2)) : 0,
new_customers: dayTwo - returning,
});
} catch (err) { next(err); }
});
One bit per customer. 1,000,000 customers = 1,000,000 bits ÷ 8 = 125,000 bytes ≈ 125 KB per day.
BITOP AND over two such keys is a bitwise AND across 125 KB of contiguous memory — the CPU compares 64 customers per machine word. BITCOUNT is a hardware popcount over the result. Both complete in well under a millisecond.
A full year of daily keys is 365 × 125 KB ≈ 45 MB. For that, you can compute retention between any two days of the year, on demand, with no pipeline and no scan.
Measure the same thing the other ways and the comparison is stark:
| Approach | Memory / storage | Cost of one retention query | Exact? |
|---|---|---|---|
Bitmap + BITOP AND | ~125 KB per day | Two commands, sub-millisecond | Yes |
Redis Set + SINTER | ~60–80 MB per day | O(smaller set) — tens of milliseconds | Yes |
| HyperLogLog | 12 KB per day | Cannot do it — PFMERGE is union only | n/a |
| MongoDB aggregation | Already stored | $match + $group + $lookup across two days of documents | Yes |
Two cautions before you ship this. First, BITOP writes a new key whose size equals the largest input — give it a short TTL, as above, or your scratch keys quietly become the biggest thing in memory. Second, the whole technique depends on a dense integer identity. CID482910 gives us one; a UUID does not. Maintain an explicit customer:offset Hash with an INCR allocator, and never hash an ID into an offset — a collision makes two customers one and silently understates the count forever.
You have now built the same dashboard twice. Same business questions, opposite engineering trade. Being able to articulate this table is the examinable skill of Module 4.
| MongoDB + Atlas Charts (L15–16) | Redis dashboard (today) | |
|---|---|---|
| Freshness | Minutes — Charts caches, and the pipeline runs on refresh | Seconds — the counter moves as the booking lands |
| Query cost | Grows with the collection; repeated per viewer | Constant — O(1)/O(log N), independent of volume |
| Flexibility | Total. Any new question is a new pipeline, answered over all history immediately | Zero. Only what you decided to track; a new metric needs code plus a backfill |
| Memory | Disk-based; the whole 15,000 documents retained | RAM-only; a few dozen keys, a few MB |
| Durability | Replicated, durable, point-in-time recoverable | Loses the last seconds of writes — acceptable only because it is derived |
| Granularity | Drill all the way down to the individual booking | Only the aggregate — the individual booking is not there |
| Build effort | Low — point Charts at a collection | Higher — writer, API, key schema, reconciliation job |
| Best use | Analysis, exploration, board reporting, anything retrospective | Operations screens, live monitoring, anything a hundred people watch at once |
The Redis Labs project this session is modelled on — redis-developer/basic-analytics-dashboard-redis-bitmaps-nodejs — is worth reading in full, and worth reading honestly.
| Reference project | Our build | |
|---|---|---|
| Event | A site visit | A ride booking |
| Bitmap tracks | Which visitors were seen on a date | Which customers booked on a date |
| Dimensions | Device, browser, traffic source | Vehicle type, pickup, status, payment method, hour |
| Headline metric | Unique visitors per period | Revenue, failure mix, daily actives, retention |
| Shared idea | Increment on write; read pre-computed aggregates; use bitmaps for uniqueness at scale. | |
Its web-analytics framing is the canonical use case — it is, after all, what Google Analytics does. Our substitution of bookings for page views changes nothing structural, which is the point: the pattern transfers across domains unchanged.
Redis gives you exactly the metrics you decided to track in advance — and nothing else.
Tomorrow your Head of Operations asks: "What is the cancellation rate for eBike bookings from IGI Airport between 17:00 and 19:00?" In MongoDB that is one $match and one $group, answered in a minute over all of 2024. In Redis the answer does not exist. You must add a key — dash:cancel:{vehicle}:{pickup}:{hour} — change the writer, deploy it, and then backfill by replaying every historical booking. Until the backfill finishes, the tile shows a number that is wrong.
And note the second-order cost: that key pattern is a combinatorial explosion. Seven vehicle types × dozens of pickups × 24 hours is thousands of keys for one question. Pre-computation is cheap for a handful of known metrics and ruinous as a general strategy.
Which is exactly the trade you made, stated correctly: you exchanged flexibility for speed. That is a good trade for eight metrics watched continuously by an operations team, and a terrible one for an analyst exploring a hypothesis. Run both. Nobody chooses between MongoDB and Redis — they choose which questions go to which.
keys.js module, because Redis has no catalogue to recover it from.booking_value and no payment_method. One unguarded INCRBYFLOAT writes NaN and permanently poisons a counter that has no audit trail.MULTI/EXEC buys consistency and throughput, not rollback. Eight updates become visible together and cost one round trip instead of eight — but a failing command does not undo the others.BITOP AND across two 125 KB daily bitmaps, then BITCOUNT. A year of daily keys is ~45 MB, and any two days can be compared on demand.You present your end-to-end build: the bdma.rides data model from Module 2, the aggregation pipelines and Atlas Charts dashboard from Module 3, and the Redis speed layer from Module 4. Twelve minutes per team, plus questions. Come prepared to defend one design decision you would now make differently — and to say which of your metrics belongs in Redis, which belongs in MongoDB, and why.