Module 4 · Lecture 18

Building a Real-Time Analytics Dashboard with Redis

A build session — compute on write, read in O(1), and count a million customers with a bitwise AND

⏱ 90 minutes 🎯 CLO3, CLO4 📍 Prerequisites: Lecture 17 (Redis structures, bitmaps, key naming) 🔧 Lab: Node 18+, redis and express, running bdma-redis container

Contents

  1. Learning Objectives
  2. Session Timeline
  3. The Architecture of a Live Dashboard
  4. Compute on Write, Not on Read
  5. The Redis Key Schema
  6. The Writer — Ingesting Bookings
  7. The Reader — An API over the Aggregates
  8. Retention with Bitmaps
  9. Redis vs Atlas Charts — and the Limits
  10. Key Takeaways

Learning Objectives

Session Timeline

TimeSegmentActivity
0–6 minRecap & Build BriefWhat we are shipping today and how it differs from the Atlas Charts dashboard
6–16 minArchitectureEvent source → writer → read API → browser; where each piece runs and why
16–28 minThe Design ShiftCompute on write vs compute on read; the cost model of each
28–40 minKey Schema DesignEight keys, five types; naming, TTLs, and handling absent fields
40–58 minBuild the WriterMULTI/EXEC, pipelining a batch, backfilling from bdma.rides
58–72 minBuild the ReaderExpress handler, the dashboard payload, a polling browser view
72–82 minRetention & ComparisonBITOP AND cohort; side-by-side against Atlas Charts
82–90 minLimits & Wrap-UpWhat a new question costs; Lecture 19 project-presentation brief

Section 1: The Architecture of a Live Dashboard

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.

Four Stages

1

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.

2

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.

3

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.

4

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.

StageRunsDoesCost per booking / per view
1. Event sourceOnce per bookingEmits the booking document—
2. WriterOnce per booking~8 Redis commands in one MULTISub-millisecond, paid once
3. Read APIOnce per dashboard poll5 reads, all O(1) or O(log N)Sub-millisecond, independent of data volume
4. BrowserEvery 2 secondsfetch + repaintOne 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.

Section 2: Compute on Write, Not on Read

This is the central idea of the lecture, and it is a direct reversal of what you did in Module 3.

What You Did in Lectures 13–16

// 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.

What You Will Do Today

// 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 happensAt query time, every timeOnce per event, at ingest
Read costGrows with data volumeConstant — O(1) or O(log N)
Write costOne insertOne insert plus ~8 counter updates
New questionWrite a new pipeline. Answer available immediately, over all historyChange the writer, redeploy, backfill. Nothing before the change exists
Total work at 1M reads of 1 metric1,000,000 aggregations1 increment + 1,000,000 cheap lookups
Right whenQuestions are varied, unpredictable, infrequent, exploratoryQuestions 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.

Section 3: The Redis Key Schema

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 patternTypePurposeWritten by
dash:total:bookingsStringAll bookings ever ingestedINCR
dash:status:{status}StringOne counter per outcome — completed, cancelled-by-driver, no-driver-found, cancelled-by-customer, incompleteINCR
dash:revenue:{YYYY-MM-DD}StringRunning completed revenue for a dateINCRBYFLOAT
dash:vehicle:revenueSorted SetRevenue per vehicle type, permanently rankedZINCRBY
dash:pickup:topSorted SetBooking count per pickup location, permanently rankedZINCRBY
dash:hour:{H}String24 hourly buckets, H = 0–23INCR
dash:customers:{YYYY-MM-DD}BitmapOne bit per customer — did they book that day?SETBIT at the numeric offset
dash:paymentHashBooking count per payment methodHINCRBY

Three Decisions Embedded in That Table

1. Status values are slugged, not raw

"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.

2. Customer offsets come from the ID

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.

3. Which keys get a TTL

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.

Section 4: The Writer — Ingesting Bookings

Project Setup

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 Code

Never 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
}

Why MULTI/EXEC

MULTI 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 Dataset

Load 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.

Section 5: The Reader — An API over the Aggregates

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'));
ReadCommandComplexityReturns
Headline countersMGETO(n) in keys requested, not dataTotal bookings, today's revenue
Status mixMGET × 5O(5)The five outcome counters
Hourly curveMGET × 24O(24)The full day's shape in one round trip
Top vehicles / pickupsZRANGE … REV WITHSCORESO(log N + 5)Already ranked, with scores
Payment mixHGETALLO(5)Every method and its count
Distinct customers todayBITCOUNTO(bitmap bytes) — 125 KB at 1M usersExact 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.

Deriving a Rate — and the Gate It Needs

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:

HourBookingsFailure rateFailed bookingsComparable?
03:004643.48%≈ 20No — sample too small
07:0063741.60%≈ 265No
16:0076440.45%≈ 309No
17:001,06239.92%≈ 424Yes — worst of these
18:001,37337.14%≈ 510Yes — 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.

The Browser View

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.

Section 6: Retention with Bitmaps

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); }
});

The Memory Argument, Concretely

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:

ApproachMemory / storageCost of one retention queryExact?
Bitmap + BITOP AND~125 KB per dayTwo commands, sub-millisecondYes
Redis Set + SINTER~60–80 MB per dayO(smaller set) — tens of millisecondsYes
HyperLogLog12 KB per dayCannot do it — PFMERGE is union onlyn/a
MongoDB aggregationAlready stored$match + $group + $lookup across two days of documentsYes

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.

Section 7: Redis vs Atlas Charts — and the Limits

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)
FreshnessMinutes — Charts caches, and the pipeline runs on refreshSeconds — the counter moves as the booking lands
Query costGrows with the collection; repeated per viewerConstant — O(1)/O(log N), independent of volume
FlexibilityTotal. Any new question is a new pipeline, answered over all history immediatelyZero. Only what you decided to track; a new metric needs code plus a backfill
MemoryDisk-based; the whole 15,000 documents retainedRAM-only; a few dozen keys, a few MB
DurabilityReplicated, durable, point-in-time recoverableLoses the last seconds of writes — acceptable only because it is derived
GranularityDrill all the way down to the individual bookingOnly the aggregate — the individual booking is not there
Build effortLow — point Charts at a collectionHigher — writer, API, key schema, reconciliation job
Best useAnalysis, exploration, board reporting, anything retrospectiveOperations screens, live monitoring, anything a hundred people watch at once

About the Reference Project

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 projectOur build
EventA site visitA ride booking
Bitmap tracksWhich visitors were seen on a dateWhich customers booked on a date
DimensionsDevice, browser, traffic sourceVehicle type, pickup, status, payment method, hour
Headline metricUnique visitors per periodRevenue, failure mix, daily actives, retention
Shared ideaIncrement 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.

The Limit You Just Bought

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.

Key Takeaways

✓ What You Should Be Able to Say After This Session

  1. Compute on write, not on read. MongoDB does the analytical work when somebody asks; Redis does it once when the booking arrives. The read then costs the same whether you have 15,000 bookings or 15,000,000.
  2. The key schema is the design document. Eight keys across five types — String, Hash, Sorted Set and Bitmap — written down in a table and owned by a single keys.js module, because Redis has no catalogue to recover it from.
  3. Guard every optional field. 5,657 of 15,000 bookings have no booking_value and no payment_method. One unguarded INCRBYFLOAT writes NaN and permanently poisons a counter that has no audit trail.
  4. 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.
  5. Two commands compute retention over a million customers. 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.
  6. You bought speed with flexibility. A new question needs a writer change, a deploy, and a backfill — and the pre-computed key space explodes combinatorially. Keep the system of record for everything you did not anticipate, and reconcile the counters against it on a schedule.

→ Next: Lecture 19 — Project Presentations

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.