---
title: 'Typical Use Cases'
source: 'https://academia.sh/en/courses/in-memory-stores/typical-use-cases'
course: 'In-Memory Stores and Caching Systems'
language: en
updated: '2026-08-23T07:00:43+00:00'
license: 'CC BY-SA 4.0'
---

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

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

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

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