Skip to content

How it works

The whole library is one idea: Redis stores small hashes in a compact encoding, so put your keys inside hashes and keep each hash small. Everything else is arithmetic to make sure the hashes stay small.

Why top-level keys are expensive

A top-level Redis key is not just its name and value. It carries an entry in the server's keyspace dictionary, object headers for the key and the value, and the allocator's own bookkeeping. That overhead is fixed per key, so it barely matters for a thousand keys and dominates for ten million tiny ones. Store a million 30-byte values as a million keys and you pay the overhead a million times, typically several times more than the data itself.

Hash fields do not work that way. A hash is one top-level key, so you pay that cost once for the whole bucket, and the fields inside are stored far more cheaply, provided the hash is small enough for Redis to keep using its compact encoding.

Listpack, and why staying in it matters

Listpack is Redis's encoding for small hashes: a single flat, contiguous allocation with entries written one after another, each prefixed by its length. There are no per-entry pointers and no hash table to maintain. It is the current name for what used to be called a ziplist, renamed in Redis 7.0.

The alternative is a real hash table, which allocates per entry and stores pointers between them. It is what you want for a hash with a hundred thousand fields, and it is exactly what you do not want for a bucket holding four hundred.

Redis picks between them on its own. You never use listpack; you keep each hash small enough that Redis keeps choosing it.

Promotion is one way

Once a hash exceeds a threshold and Redis converts it to a hash table, it stays a hash table. Deleting fields afterwards does not convert it back. A bucket that briefly grew too large is permanently more expensive until you delete the key and rebuild it.

The two thresholds

Two server settings decide the encoding, and crossing either one promotes the hash:

SettingDefaultApplies to
hash-max-listpack-entries512Number of fields in the hash
hash-max-listpack-value64 bytesEach field name and each value

The second one catches people out. Your logical key is stored as the hash field name, so the key is measured against the same 64-byte limit as the value. And the check is per entry but the consequence is per hash: one 80-byte key promotes the entire bucket it landed in, along with the several hundred well-behaved entries sharing it.

redis-packhash only manages the first threshold. Key and value lengths are yours to keep in range. See the other listpack limit.

Bucketing

The mapping is a hash and a modulo, and that is all:

ts
bucketKeyFor(key: string): string {
  return `${this.namespace}:${fnv1a(key) % this.buckets}`;
}

So a write is one ordinary HSET, with the bucket computed locally:

store.set("f47ac10b-58cc-4372-a567-0e02b2c3d479", val)

        ├─ fnv1a(key)        → 1533351232
        ├─ % 2605            → 1342
        └─ HSET  users:1342  "f47ac10b-58cc-4372-a567-0e02b2c3d479"  val

This is worth stating plainly because of what it does not involve. There is no registry of which key lives where, no lookup before the write, no extra round trip, and no state in the client beyond the bucket count. Two processes with the same namespace and the same expectedKeys compute the same bucket for the same key without coordinating. That is the entire reason the scheme is client-agnostic and works on Redis Cluster.

Why FNV-1a

FNV-1a is a non-cryptographic hash chosen for being fast and well distributed. The implementation is eight lines:

ts
export function fnv1a(input: string): number {
  let hash = 0x811c9dc5; // FNV offset basis
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193); // FNV prime, 32-bit safe multiply
  }
  return hash >>> 0; // coerce to unsigned 32-bit
}

The "1a" variant XORs the byte before multiplying, which gives better avalanche than plain FNV-1: flipping one bit of the input changes roughly half the output bits, so keys that differ by a single character land in unrelated buckets.

It is not cryptographic and does not try to be. Nothing here depends on the hash being hard to invert, only on it spreading keys evenly and running fast enough to be free next to a network round trip.

Where the 0.75 comes from

Sizing targets 75% of the entry limit rather than 100%:

buckets = ceil(expectedKeys / floor(maxListpackEntries × 0.75))

At the default 512 that is 384 fields per bucket, not 512. Giving away a quarter of the capacity looks wasteful until you measure what happens without it.

FNV-1a distributes well, but "well" is not "perfectly". Hashing a million random UUIDs and counting where they land:

Load factorBucketsMean per bucketFullest bucketBuckets promoted
1.00 (naive)1,954512590949 (48.6%)
0.902,17446053012 (0.6%)
0.75 (used here)2,6053844540

Size for exactly 512 per bucket and roughly half your buckets promote immediately, on the very dataset you sized for. The fullest bucket runs about 18% above the mean, so the mean has to sit far enough below the limit to absorb that. At 0.75 the fullest bucket reached 454 of 512, using 89% of the limit and leaving room for the dataset to grow past your estimate.

Exact figures shift a little between runs, since the keys are random. The shape does not.

Why the bucket count cannot change

The bucket count is the modulus. Change it and fnv1a(key) % buckets returns a different bucket for almost every key, so lookups miss and the old entries sit in Redis as orphans, unreachable and still consuming memory.

There is no rehashing and no migration. The library holds no record of what it wrote, which is what keeps it stateless, and it is also why it cannot move anything for you.

In practice this means expectedKeys is a deployment-time decision for a populated dataset. Small edits are free, because only the resolved count matters: 1,000,000 and 1,000,100 both resolve to 2,605 buckets, so nothing moves. Check with computeBuckets before changing it. To genuinely resize, write to a new namespace and migrate.

What the design costs

The same statelessness that makes this cheap also removes things you may expect:

  • No TTL. A key is a hash field, and EXPIRE targets keys, not fields.
  • No enumeration. No keys() or scan(). Walk the buckets yourself with HSCAN over <namespace>:0<namespace>:N-1.
  • No atomic bulk ops. mset and mget issue individual commands so they keep working on Redis Cluster, where buckets span hash slots.

If those are dealbreakers, plain top-level keys are the right tool and the memory cost is what you pay for the features.

Next steps

Released under the MIT License.