A build session โ compute on write, read in O(1), and count a million customers with a bitwise AND.
Eight live metrics over the bdma.rides dataset you have been modelling since Module 2:
Total bookings ยท revenue today ยท distinct customers active today
Status mix ยท revenue by vehicle ยท top 5 pickups ยท hourly curve ยท payment split
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.
| Stage | Runs | Does | Cost |
|---|---|---|---|
| 1. Event source | Once per booking | Emits the booking document | โ |
| 2. Writer | Once per booking | ~8 Redis commands in one MULTI | Sub-ms, paid once |
| 3. Read API | Per dashboard poll | 5 reads, all O(1) or O(log N) | Sub-ms, volume-independent |
| 4. Browser | Every 2 seconds | fetch + repaint | One HTTP request |
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.
data/uber_bookings.json at a few per second.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.
// 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.
| Compute on read (Mongo) | Compute on write (Redis) | |
|---|---|---|
| Read cost | Grows with data volume | Constant โ O(1) / O(log N) |
| Write cost | One insert | One insert + ~8 counter updates |
| New question | New pipeline โ answered over all history | Code change + deploy + backfill |
| 1M reads of 1 metric | 1,000,000 aggregations | 1 increment + 1M cheap lookups |
| Right when | Questions are varied and exploratory | Questions are fixed and asked constantly |
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.
| Key pattern | Type | Purpose | Written by |
|---|---|---|---|
dash:total:bookings | String | All bookings ingested | INCR |
dash:status:{status} | String | One counter per outcome (5) | INCR |
dash:revenue:{YYYY-MM-DD} | String | Running completed revenue | INCRBYFLOAT |
dash:vehicle:revenue | ZSET | Revenue per vehicle, ranked | ZINCRBY |
dash:pickup:top | ZSET | Bookings per pickup, ranked | ZINCRBY |
dash:hour:{H} | String | 24 hourly buckets | INCR |
dash:customers:{YYYY-MM-DD} | Bitmap | One bit per customer per day | SETBIT |
dash:payment | Hash | Count per payment method | HINCRBY |
"Cancelled by Driver" โ dash:status:cancelled-by-driver. Spaces are legal and a permanent nuisance in CLI, logs and scripts.
CID482910 โ 482910. Dense integer identity, free. A UUID system needs a customer:offset Hash and an INCR allocator.
Daily keys: 90 days. Cumulative keys: none. With volatile-lru, pressure evicts old days and never the live aggregates.
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.
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}`,
};
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.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.
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.
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.
// 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
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); }
});
$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 | Command | Complexity |
|---|---|---|
| Headline + status + hours | MGET | O(keys requested), not O(data) |
| Top vehicles / pickups | ZRANGE โฆ REV WITHSCORES | O(log N + 5), pre-sorted |
| Payment mix | HGETALL | O(5) |
| Distinct customers today | BITCOUNT | O(bitmap bytes) โ 125 KB at 1M users |
| Failure rate by hour | MGET ร 24 twice, then divide | O(48) โ a rate needs two counters |
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.
$match everyone inherits. In Redis it lives in your read code โ so the next service to read these keys can re-introduce the bug.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
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
A bitwise AND over 125 KB of contiguous memory โ 64 customers per machine word โ then a hardware popcount. Both well under a millisecond.
365 ร 125 KB โ 45 MB, and you can compare any two days of the year on demand. No pipeline, no scan.
| Approach | Memory/day | Query cost | Exact? |
|---|---|---|---|
Bitmap + BITOP AND | ~125 KB | 2 commands, sub-ms | Yes |
Set + SINTER | ~60โ80 MB | Tens of ms | Yes |
| HyperLogLog | 12 KB | Cannot โ PFMERGE is union only | n/a |
BITOP destination a short TTL, and never hash an ID into an offset โ a collision silently understates the count forever.| MongoDB + Atlas Charts (L15โ16) | Redis (today) | |
|---|---|---|
| Freshness | Minutes โ cached, pipeline on refresh | Seconds โ moves as the booking lands |
| Query cost | Grows with the collection, per viewer | Constant, volume-independent |
| Flexibility | Total โ new pipeline, all history | Zero โ only what you chose to track |
| Memory | Disk; all 15,000 documents | RAM; a few dozen keys |
| Durability | Replicated, point-in-time recovery | Loses the last seconds โ OK only because derived |
| Granularity | Drill to the individual booking | Aggregate only |
| Best use | Analysis, exploration, board reporting | Operations screens watched by 100 people |
"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.
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.
redis-developer/basic-analytics-dashboard-redis-bitmaps-nodejs โ same pattern, tracking site visits and devices instead of bookings.keys.js โ Redis has no catalogue to recover it from.booking_value; one NaN poisons a counter permanently.MULTI/EXEC buys consistency and throughput, not rollback. Eight updates, one round trip, visible together.BITOP AND + BITCOUNT, at ~125 KB per day.