โ† Home Lecture 18 ยท Real-Time Analytics Dashboard with Redis
1 / 18 ๐Ÿ“„ Notes
Module 4 ยท Lecture 18

Building a Real-Time Analytics Dashboard

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

โฑ 90 minutes ๐ŸŽฏ CLO3 ยท CLO4 โŒจ๏ธ Node 18+ ยท redis ยท express
The Build Brief

An operations screen, refreshing every 2 seconds

Eight live metrics over the bdma.rides dataset you have been modelling since Module 2:

Headline

Total bookings ยท revenue today ยท distinct customers active today

Breakdowns

Status mix ยท revenue by vehicle ยท top 5 pickups ยท hourly curve ยท payment split

The hard one

Day-over-day retention โ€” over a million customers, in two commands

Modelled on Redis Labs' basic-analytics-dashboard-redis-bitmaps-nodejs, rebuilt on our ride-booking domain.

You built this dashboard once already, in Atlas Charts. Today you build it with the opposite engineering trade โ€” and learn to say which is right when.
Architecture

Four stages, one direction

StageRunsDoesCost
1. Event sourceOnce per bookingEmits the booking documentโ€”
2. WriterOnce per booking~8 Redis commands in one MULTISub-ms, paid once
3. Read APIPer dashboard poll5 reads, all O(1) or O(log N)Sub-ms, volume-independent
4. BrowserEvery 2 secondsfetch + repaintOne HTTP request

Where MongoDB sits

Still underneath, still holding all 15,000 documents. Redis never replaces it. Lose a key or add a metric โ†’ re-run stage 2 over bdma.rides. The system of record is what makes the speed layer disposable, and therefore safe.

In the lab, stage 1 is a replayer streaming data/uber_bookings.json at a few per second.
The Central Shift

Compute on write, not on read

Module 3 โ€” on read

db.rides.aggregate([
  { $match: { status: "Completed" } },
  { $group: { _id: "$vehicle_type",
      revenue: { $sum: "$booking_value" } } },
  { $sort: { revenue: -1 } },
  { $limit: 5 }
])

Re-executed in full every time the tile renders. Cost grows with the business.

Today โ€” on write

// once, as the booking lands
ZINCRBY dash:vehicle:revenue
        412.50 "Go Sedan"

// forever after:
ZREVRANGE dash:vehicle:revenue
          0 4 WITHSCORES

The aggregate is maintained incrementally. The read is a slice of an already-sorted structure.

Same answer. The entire difference is when the work happens โ€” and therefore who waits for it.
The Cost Model

Which trade are you making?

Compute on read (Mongo)Compute on write (Redis)
Read costGrows with data volumeConstant โ€” O(1) / O(log N)
Write costOne insertOne insert + ~8 counter updates
New questionNew pipeline โ†’ answered over all historyCode change + deploy + backfill
1M reads of 1 metric1,000,000 aggregations1 increment + 1M cheap lookups
Right whenQuestions are varied and exploratoryQuestions are fixed and asked constantly

โš ๏ธ The hidden cost

Pre-computation moves work to the write path โ€” and moves risk with it. A writer bug corrupts a counter permanently; no re-query fixes it. Your repair procedure is "delete the key, replay bdma.rides" โ€” test it before you need it.

Rule: high read-to-write ratio + stable question โ†’ pre-compute. Otherwise compute on demand. Most firms run both over the same data.
Design ยท Step 1

The key schema IS the design document

Key patternTypePurposeWritten by
dash:total:bookingsStringAll bookings ingestedINCR
dash:status:{status}StringOne counter per outcome (5)INCR
dash:revenue:{YYYY-MM-DD}StringRunning completed revenueINCRBYFLOAT
dash:vehicle:revenueZSETRevenue per vehicle, rankedZINCRBY
dash:pickup:topZSETBookings per pickup, rankedZINCRBY
dash:hour:{H}String24 hourly bucketsINCR
dash:customers:{YYYY-MM-DD}BitmapOne bit per customer per daySETBIT
dash:paymentHashCount per payment methodHINCRBY
Eight keys, five types. Redis has no catalogue โ€” if this table is not in the repo, the schema does not exist.
Design ยท Step 2

Three decisions โ€” and one trap

Slug the status

"Cancelled by Driver" โ†’ dash:status:cancelled-by-driver. Spaces are legal and a permanent nuisance in CLI, logs and scripts.

Derive the offset

CID482910 โ†’ 482910. Dense integer identity, free. A UUID system needs a customer:offset Hash and an INCR allocator.

TTL only date keys

Daily keys: 90 days. Cumulative keys: none. With volatile-lru, pressure evicts old days and never the live aggregates.

โš ๏ธ The field-absence trap

booking_value, ride_distance, ratings and payment_method are absent on cancelled and no-driver-found bookings โ€” 5,657 of 15,000 documents (37.71%). One unguarded INCRBYFLOAT key undefined writes NaN and poisons a counter that has no audit trail.

Every optional field needs a guard, and every guard needs a reason you can state out loud.
Code ยท keys.js

One module owns every key string

export const slug = (s) => s.toLowerCase().replace(/\s+/g, '-');

// "CID482910" -> 482910. 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}`,
};
Never build a key inline. A rename becomes one edit, and a typo becomes impossible rather than a silently orphaned counter.
Code ยท writer.js

One booking, one transaction

function queueBooking(tx, b) {
  const day  = b.date;                              // "2024-03-14"
  const hour = new Date(b.booked_at).getUTCHours(); // 18

  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);
  tx.expire(K.customers(day), NINETY_DAYS, 'NX');   // NX: don't push the window

  if (typeof b.booking_value === 'number') {        // absent on cancellations
    tx.incrByFloat(K.revenue(day), b.booking_value);
    tx.zIncrBy(K.vehicleRevenue, b.booking_value, b.vehicle_type);
  }
  if (b.payment_method) tx.hIncrBy(K.payment, b.payment_method, 1);
}

export async function recordBooking(b) {
  const tx = redis.multi(); queueBooking(tx, b); return tx.exec();
}
export async function recordBatch(list) {
  const tx = redis.multi(); for (const b of list) queueBooking(tx, b);
  return tx.exec();                                 // one round trip, N bookings
}
queueBooking performs no await โ€” which is why the same function serves a single live write and a batched backfill.
Why MULTI

Consistency and throughput โ€” not rollback

Consistency between keys

Without it, a poll landing mid-write reads a total of 9,344 against a status breakdown still summing to 9,343. Small, embarrassing, impossible to reproduce. With MULTI, all eight updates appear at once or not at all.

Throughput

8 awaited commands = 8 round trips. Queued on one MULTI = 1. At 0.5 ms per trip that is 4 ms vs 0.5 ms per booking โ€” 250/s vs 2,000/s.

โš ๏ธ There is no rollback

If one command fails at runtime โ€” say you INCR a key holding a Hash โ€” the rest still run and the earlier ones stand. Redis guarantees isolation and ordering, not atomic undo. Correctness comes from guarded inputs, not from a safety net.

Single-threaded execution is what makes this cheap: no other client's command can interleave inside the block.
Backfill & Verify

Load 15,000 bookings, then check your work

// replay.js โ€” batches of 500, then dribble the tail so the page moves
for (let i = 0; i < history.length; i += 500)
  await recordBatch(history.slice(i, i + 500));
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%)
GET dash:hour:18                        # -> 1373, the busiest hour
ZREVRANGE dash:vehicle:revenue 0 2 WITHSCORES
# Premier Sedan 536855 ยท Go Sedan 491473 ยท Uber XL 431927
Reconciliation is not optional. A pre-computed counter has no audit trail โ€” the only proof it is right is a check against the system of record. In production this is a nightly job that alarms on divergence.
Code ยท api.js

The reader does no analytical work

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: num(headline[1]), active_customers: actives,
      vehicle_revenue: vehicles.map(({ value, score }) =>
        ({ vehicle_type: value, revenue: score })), /* ... */ });
  } catch (err) { next(err); }
});
No $group. No $sort. No scan โ€” every number already exists. And Promise.all, not seven sequential awaits: same commands, one round trip of latency instead of seven.
Read Costs

Five reads, none of which grow

ReadCommandComplexity
Headline + status + hoursMGETO(keys requested), not O(data)
Top vehicles / pickupsZRANGE โ€ฆ REV WITHSCORESO(log N + 5), pre-sorted
Payment mixHGETALLO(5)
Distinct customers todayBITCOUNTO(bitmap bytes) โ€” 125 KB at 1M users
Failure rate by hourMGET ร— 24 twice, then divideO(48) โ€” a rate needs two counters

And a rate needs a gate

Ranked by failure rate alone, 03:00 tops the day at 43.48% โ€” on 46 bookings all year. Noise. Gate on volume (bookings >= 1000) and the real finding appears: 17:00 at 39.92%, worse than the 18:00 peak's 37.14%, though 18:00 takes 1,373 bookings to 17:00's 1,062.

In Mongo that gate is one $match everyone inherits. In Redis it lives in your read code โ€” so the next service to read these keys can re-introduce the bug.
The Browser ยท Run It

Poll, repaint, nothing clever

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('');
}
refresh(); setInterval(refresh, 2000);
node api.js      # terminal 1 โ€” http://localhost:3000
node replay.js   # terminal 2 โ€” watch the numbers climb
Polling vs pushing: four lines, survives a dropped connection with no reconnect logic, and the read is nearly free. SSE or WebSockets earn their complexity only at sub-second updates or thousands of clients.
The Payoff

Retention over a million customers โ€” two commands

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
125 KB
per day ยท 1,000,000 bits รท 8

What the CPU does

A bitwise AND over 125 KB of contiguous memory โ€” 64 customers per machine word โ€” then a hardware popcount. Both well under a millisecond.

A year of it

365 ร— 125 KB โ‰ˆ 45 MB, and you can compare any two days of the year on demand. No pipeline, no scan.

ApproachMemory/dayQuery costExact?
Bitmap + BITOP AND~125 KB2 commands, sub-msYes
Set + SINTER~60โ€“80 MBTens of msYes
HyperLogLog12 KBCannot โ€” PFMERGE is union onlyn/a
Two cautions: give the BITOP destination a short TTL, and never hash an ID into an offset โ€” a collision silently understates the count forever.
Same Questions, Opposite Trade

Redis dashboard vs Atlas Charts

MongoDB + Atlas Charts (L15โ€“16)Redis (today)
FreshnessMinutes โ€” cached, pipeline on refreshSeconds โ€” moves as the booking lands
Query costGrows with the collection, per viewerConstant, volume-independent
FlexibilityTotal โ€” new pipeline, all historyZero โ€” only what you chose to track
MemoryDisk; all 15,000 documentsRAM; a few dozen keys
DurabilityReplicated, point-in-time recoveryLoses the last seconds โ€” OK only because derived
GranularityDrill to the individual bookingAggregate only
Best useAnalysis, exploration, board reportingOperations screens watched by 100 people
Being able to argue this table is the examinable skill of Module 4.
Honest Limits

You get the metrics you chose in advance

Tomorrow's question

"What is the cancellation rate for eBike bookings from IGI Airport between 17:00 and 19:00?"

In MongoDB: one $match, one $group, answered in a minute over all of 2024. In Redis: the answer does not exist. Add a key, change the writer, deploy, backfill โ€” and until the backfill finishes, the tile is wrong.

Worse, dash:cancel:{vehicle}:{pickup}:{hour} is a combinatorial explosion: 7 vehicles ร— dozens of pickups ร— 24 hours = thousands of keys for one question.

Which is exactly the trade you made

You exchanged flexibility for speed. A good trade for eight metrics watched continuously. A terrible one for an analyst testing a hypothesis. Nobody chooses between MongoDB and Redis โ€” they choose which questions go to which.

Further reading: redis-developer/basic-analytics-dashboard-redis-bitmaps-nodejs โ€” same pattern, tracking site visits and devices instead of bookings.
Wrap-Up

Six things to carry forward

  1. Compute on write, not on read. The read then costs the same at 15,000 bookings or 15,000,000.
  2. The key schema is the design document. Eight keys, five types, one keys.js โ€” Redis has no catalogue to recover it from.
  3. Guard every optional field. 5,657 of 15,000 bookings have no booking_value; one NaN poisons a counter permanently.
  4. MULTI/EXEC buys consistency and throughput, not rollback. Eight updates, one round trip, visible together.
  5. Two commands compute retention over a million customers. BITOP AND + BITCOUNT, at ~125 KB per day.
  6. You bought speed with flexibility. Keep the system of record for every question you did not anticipate โ€” and reconcile against it on a schedule.
Next โ€” Lecture 19: project presentations. 12 minutes per team on your end-to-end build. Come ready to defend one decision you would now make differently, and to say which metrics belong in Redis, which in MongoDB, and why.
โ† โ†’ or Space to navigate ยท F for fullscreen