Lesson 20 / 22
Typical Use Cases
Fitting the catalog cache, session records, a rate limit, and a leaderboard onto the in-memory store's structures: which structure each scenario is built with, how many bytes it holds per entry, and what access that byte count buys.
Contents
The previous lesson measured the publish-subscribe path and ended on a limit: because that path saves nothing, it held 52 bytes, and in exchange the 700 events of a dropped subscriber never came back. That was a single instance of this course’s recurring question. The same question gets asked of every one of the store’s everyday jobs.
This lesson takes four typical jobs from the library system — the catalog cache, session records, a rate limit, a popular-book leaderboard — and reduces each one to a single question: which structure is it built with, and how many bytes does it hold. What the caching strategy should be, which algorithm the rate limit runs on, are not discussed here; those choices were measured in other courses. What gets measured here is the structure itself.
Shared Cost Model
All four accounts use the same counting rule (CU13): 16 bytes of pointer/length overhead per entry, 8 bytes of overhead per field in a hash structure, 24 bytes of index-node overhead per member in a sorted set, 8 bytes per numeric value. Keys and string values are read from their real length. This model is not a product’s internal layout, it is this lesson’s own accounting contract; what matters is that the two options are counted by the same rule.
Catalog Cache and Session Records
The first two scenarios pick the same structure, a hash structure, for two different reasons. In the catalog cache, the question is whether the record is held whole or field by field. In session records, the question is how long the records are held for: session data is set up together with an expiration, and memory scales not with the number of sessions opened but with the number live at the same time.
// catalog-session.mjs — catalog cache and session records: which structure, how many bytes const ENTRY_OVERHEAD = 16, FIELD_OVERHEAD = 8, NUMBER_BYTES = 8; // my own cost model (entry/field/number overhead) const b = (s) => Buffer.byteLength(String(s)); const fieldBytes = (name, value) => // cost of one field in a hash structure b(name) + (typeof value === "number" ? NUMBER_BYTES : b(value)) + FIELD_OVERHEAD; function books(count) { let seed = 20250731; const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648); const tags = ["fiction", "history", "children", "science"]; return Array.from({ length: count }, (_, i) => ({ bookId: i + 1, title: `Book ${i + 1}`, author: `Author ${Math.floor(rand() * 120) + 1}`, branch: Math.floor(rand() * 3) + 1, shelf: Math.floor(rand() * 5), tag: tags[Math.floor(rand() * 4)], })); } // --- Scenario 1: catalog cache. The same record held in two structures. const records = books(400); let stringBytes = 0, hashBytes = 0, stringWrites = 0, hashWrites = 0; for (const k of records) { const key = `book:${k.bookId}`; const body = JSON.stringify(k); stringBytes += b(key) + b(body) + ENTRY_OVERHEAD; // single string: body held whole hashBytes += b(key) + ENTRY_OVERHEAD + // hash structure: field by field Object.entries(k).reduce((t, [name, d]) => t + fieldBytes(name, d), 0); } for (let i = 0; i < 1000; i += 1) { // 1000 shelf updates: only `shelf` changes const k = records[i % records.length]; stringWrites += b(JSON.stringify(k)); // string: the whole body is rewritten hashWrites += NUMBER_BYTES; // hash: only that field's value } console.log("catalog cache (400 books)"); console.log(` string : held=${stringBytes} bytes per entry=${Math.round(stringBytes / 400)}` + ` written over 1000 updates=${stringWrites} bytes`); console.log(` hash : held=${hashBytes} bytes per entry=${Math.round(hashBytes / 400)}` + ` written over 1000 updates=${hashWrites} bytes`); // --- Scenario 2: session records. Hash structure + expiration; expiration bounds live sessions. const MINUTES = 720, ENTRIES_PER_MIN = 25, LIFETIME = 30; // 12 hours, 25 entries/minute, 30 min lifetime const sessionFields = { userId: 1, branch: 1, role: "member", lastAccess: 1 }; const sessionBytes = b("session:") + 32 + ENTRY_OVERHEAD + // key: prefix + 32-char id Object.entries(sessionFields).reduce((t, [name, d]) => t + fieldBytes(name, d), 0); const live = new Map(); // id -> expiry (minute) let peakLive = 0, opened = 0; for (let m = 1; m <= MINUTES; m += 1) { for (const [id, exp] of live) if (exp <= m) live.delete(id); // expiration cleans up for (let i = 0; i < ENTRIES_PER_MIN; i += 1) live.set(`s${opened++}`, m + LIFETIME); peakLive = Math.max(peakLive, live.size); } console.log("session records (12 hours)"); console.log(` per entry=${sessionBytes} bytes sessions opened=${opened} peak live=${peakLive}`); console.log(` with expiration : held=${peakLive * sessionBytes} bytes`); console.log(` without expiration : held=${opened * sessionBytes} bytes` + ` (${(opened / peakLive).toFixed(1)}x)`);
catalog cache (400 books) string : held=45825 bytes per entry=115 written over 1000 updates=90728 bytes hash : held=60333 bytes per entry=151 written over 1000 updates=8000 bytes session records (12 hours) per entry=144 bytes sessions opened=18000 peak live=750 with expiration : held=108000 bytes without expiration : held=2592000 bytes (24.0x)
The catalog rows’ trade-off is clear-cut. The hash structure holds 14,508 bytes more (151 bytes per entry instead of 115), because every field’s name and every field’s overhead get paid separately. What that buys is in the second column: over a thousand shelf updates, the string form rewrites 90,728 bytes while the hash structure writes 8,000. When a record is held whole, changing a single number means rewriting the entire body; the body also has to be parsed and rebuilt.
The choice depends on how the record gets used. If the record is always read whole and rarely updated, the string form holds fewer bytes and finishes in a single access. If fields are read or updated individually, the hash structure’s extra 36 bytes are paid back by the roughly 83 bytes saved on writes with every update.
The session rows’ trade-off sits on a different axis. At 144 bytes per entry, holding all 18,000 sessions opened over twelve hours would take 2,592,000 bytes. Expiration keeps the sessions live at any one moment at 750 and cuts the cost to 108,000 bytes: twenty-four times over. Here, memory scales not with user count but with session lifetime. What gets lost is just as clear: an expired session’s data cannot be recovered, and the user authenticates again (CU14).
Rate Limit and Leaderboard
The last two scenarios are two ends of the same question: is a number by itself enough, or are the individual entries that make up that number needed too. In the rate limit, the counter holds only the window’s total; a sorted set holds each request’s moment as a separate member. In the leaderboard, the hash structure holds only the loan counts; a sorted set carries a sorted index on top of them.
// rate-leaderboard.mjs — rate limit and leaderboard: which structure, how many bytes, how many entries touched const ENTRY_OVERHEAD = 16, FIELD_OVERHEAD = 8, INDEX_OVERHEAD = 24, NUMBER_BYTES = 8; // same cost model const b = (s) => Buffer.byteLength(String(s)); // --- Scenario 3: rate limit. Counter holds the window's total, sorted set holds each moment. const USERS = 2000, LIMIT = 60; // 60 requests per minute let counterBytes = 0, setBytes = 0; for (let u = 1; u <= USERS; u += 1) { counterBytes += b(`rate:${u}:2025073114`) + NUMBER_BYTES + ENTRY_OVERHEAD; // window is in the key let member = b(`rate:${u}`) + ENTRY_OVERHEAD; for (let i = 0; i < LIMIT; i += 1) // one member per request: timestamp + sequence member += b(`1753963200000-${i}`) + NUMBER_BYTES + INDEX_OVERHEAD; setBytes += member; } console.log(`rate limit (${USERS} users, ${LIMIT} requests/minute)`); console.log(` counter : held=${counterBytes} bytes per user=${(counterBytes / USERS).toFixed(1)}` + ` entries=${USERS}`); console.log(` sorted set : held=${setBytes} bytes per user=${(setBytes / USERS).toFixed(1)}` + ` entries=${USERS * LIMIT} (${(setBytes / counterBytes).toFixed(1)}x)`); // --- Scenario 4: leaderboard. Same counts; the difference is whether a sorted index is held. function loanCounts(count) { let seed = 20250731; const rand = () => ((seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648); return Array.from({ length: count }, (_, i) => [`Book ${i + 1}`, Math.floor(rand() * 900) + 1]); } const counts = loanCounts(400); let hashBytes = b("popular") + ENTRY_OVERHEAD, sortedSetBytes = b("popular") + ENTRY_OVERHEAD; for (const [member] of counts) { hashBytes += b(member) + NUMBER_BYTES + FIELD_OVERHEAD; // just name and count sortedSetBytes += b(member) + NUMBER_BYTES + INDEX_OVERHEAD; // plus a sorted-index node on top } const sorted = [...counts].sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)); let hashTouched = 0, setTouched = 0; const topTen = sorted.slice(0, 10); // sorted set: first ten entries from the index setTouched += topTen.length; hashTouched += counts.length; // hash: everything gets scanned and sorted const targetScore = counts[136][1]; // where "Book 137" ranks let hashHigher = 0; // hash answers by a full scan for (const [, s] of counts) if (s > targetScore) hashHigher += 1; hashTouched += counts.length; let lo = 0, hi = sorted.length - 1, steps = 0; // binary search in the sorted set while (lo <= hi) { const mid = (lo + hi) >> 1; steps += 1; if (sorted[mid][1] > targetScore) lo = mid + 1; else hi = mid - 1; } setTouched += steps; console.log(`leaderboard (${counts.length} books)`); console.log(` hash : held=${hashBytes} bytes touched for top-ten + rank=${hashTouched}`); console.log(` sorted set : held=${sortedSetBytes} bytes touched for top-ten + rank=${setTouched}` + ` (${(sortedSetBytes / hashBytes).toFixed(2)}x bytes)`); console.log(` top three: ${topTen.slice(0, 3).map(([a, s]) => `${a}=${s}`).join(" ")}`); console.log(` "Book 137" score=${targetScore}; books with a higher score: hash=${hashHigher}` + ` (400 entries) set=${lo} (${steps} entries)`);
rate limit (2000 users, 60 requests/minute) counter : held=86893 bytes per user=43.4 entries=2000 sorted set : held=5788893 bytes per user=2894.4 entries=120000 (66.6x) leaderboard (400 books) hash : held=9515 bytes touched for top-ten + rank=800 sorted set : held=15915 bytes touched for top-ten + rank=19 (1.67x bytes) top three: Book 141=900 Book 211=900 Book 125=897 "Book 137" score=533; books with a higher score: hash=146 (400 entries) set=146 (9 entries)
The rate limit’s difference is sixty-seven times over, and the reason fits in one sentence: the counter holds one entry per user, the sorted set holds sixty. 2,894.4 bytes get paid per user instead of 43.4. What that buys is knowing the moment of every request inside the window; the counter does not know this, it only knows how many there were. Whether that knowledge is needed depends on how the limit is meant to work, and that decision is outside this lesson; what gets measured here is the price.
In the leaderboard, the ratio is far smaller: 1.67 times, that is, 6,400 bytes. What that buys shows up in the last two rows. Finding the top ten books and one book’s rank requires touching 800 entries in the hash structure (two full scans), while 19 entries are enough in the sorted set (ten entries read from the front, the rank found by a nine-step binary search). Both paths give the same answer: there are 146 books that were loaned more often than “Book 137”.
A sorted set’s index overhead is paid once but earns itself back on every query. When the catalog holds 400,000 books instead of 400, the hash structure’s full scan grows to 800,000 entries while the sorted set’s work stays at 10 + 19 entries; the extra bytes paid grow directly in proportion to entry count.
Four Scenarios in One Table
| Scenario | Structure | Entries | Bytes held | What it buys | What it loses |
|---|---|---|---|---|---|
| Catalog cache | hash structure | 400 | 60,333 | an 8-byte write per field (91 bytes for the string form) | 14,508 bytes versus the string form |
| Session records | hash structure + expiration | 750 live | 108,000 | bounding memory to session lifetime | the data of an expired session |
| Rate limit | counter | 2,000 | 86,893 | a single entry per user | the individual moments of requests |
| Leaderboard | sorted set | 400 | 15,915 | 19 entries for the top-ten-and-rank query | 6,400 bytes versus the hash structure |
The table looks like four separate jobs, but it repeats a single kind of decision. In every row, the structure is the answer to the question of which information has to be held for that job. The more detail is held, the cheaper access gets and the more expensive memory gets; the hash structure cuts writes because it separates fields, the sorted set cuts the rank query because it keeps the order, the counter is cheap because it throws the detail away — and the detail it throws away does not come back.
Summary
- The same record’s string form held 45,825 bytes, its hash form 60,333; the hash structure’s 14,508-byte excess was paid back by writing 8,000 bytes instead of 90,728 over a thousand updates.
- In session records, memory scales with session lifetime, not user count: expiration cut 18,000 sessions to 750 live ones, 108,000 bytes instead of 2,592,000.
- In the rate limit, the counter holds 43.4 bytes per user, the sorted set 2,894.4; the sixty-seven-fold difference is the price of storing each request’s individual moment.
- In the leaderboard, the sorted set’s index overhead is 6,400 bytes, and it cuts the top-ten-and-rank query from 800 entries to 19; both paths give the same answer.
- In all four scenarios, the structure choice comes down to a single question: which information gets held. The more detail is held, the cheaper access gets and the more expensive memory gets.
Next Step
Every accounting in this lesson assumed the store behaves as expected: every access happens the moment its turn comes, no request waits on another. Most in-memory stores process commands in order on a single thread, and that assumption breaks in a particular way there: a single command scanning 400,000 entries delays every request waiting behind it by its own duration. The next lesson measures that delay, counts the number of waiting requests, and shows how sweeping the slow command log’s threshold makes the problem command visible.
To keep your progress and take notes, Log in
My notes
Log in to take notes.