Configuration
Every option goes to the constructor. All three are optional, and the defaults are chosen so a store built with no options at all is still correct, just conservatively sized.
const store = new PackHash(client, {
expectedKeys: 1_000_000, // no default; falls back to 1024 buckets
namespace: "users", // default: "ph"
maxListpackEntries: 512, // default: 512
});Options are validated once, in the constructor, so a bad value fails at startup rather than on your first command.
expectedKeys
Type: number. Optional. No default.
Roughly how many keys you expect to store. The bucket count is derived from it, so you never compute buckets by hand:
buckets = ceil(expectedKeys / floor(maxListpackEntries × 0.75))The 0.75 is a fixed internal load factor. Targeting 75% of the entry limit rather than 100% leaves headroom for two things: FNV-1a spreads keys well but not perfectly evenly, and datasets grow after you pick a number.
expectedKeys | Buckets (at the default 512) |
|---|---|
| 10,000 | 27 |
| 100,000 | 261 |
| 1,000,000 | 2,605 |
| 3,000,000 | 7,813 |
| omitted | 1,024 |
When omitted, the store uses a flat 1024 buckets. Even distribution would put the 512-entry limit at 524k keys, but keys are not evenly distributed: with random UUIDs the fullest bucket crosses 512 at around 460k keys total, and exactly where depends on your keys. It is a fallback, not a recommendation. Pass a real number if you know one.
Changing this remaps every key
The bucket count is the modulus in fnv1a(key) % buckets. Change it enough to change the resolved bucket count and every key hashes somewhere new: existing entries become unreachable and sit in Redis as orphans, and get returns null for data that is still there.
Treat expectedKeys as fixed once a dataset is populated. If you genuinely have to resize, write to a new namespace and migrate, rather than editing the number in place.
Note that small changes are often free. Going from 1_000_000 to 1_000_100 still resolves to 2,605 buckets, so nothing moves. It is the resolved bucket count that matters, not the input. Use computeBuckets to check before you deploy a change:
import { computeBuckets } from "redis-packhash";
const before = computeBuckets({ expectedKeys: 1_000_000, maxListpackEntries: 512 });
const after = computeBuckets({ expectedKeys: 1_200_000, maxListpackEntries: 512 });
if (before !== after) throw new Error("this change remaps every key");Validation: must be a finite, non-negative number, or a TypeError is thrown. Note that it is not required to be an integer.
namespace
Type: string. Default: "ph".
The prefix for every Redis key this store creates. Buckets are named <namespace>:<n>, from <namespace>:0 up to <namespace>:<buckets - 1>.
new PackHash(client, { namespace: "users" }) //
.bucketKeyFor("f47ac10b-58cc-4372-a567-0e02b2c3d479");
//=> "users:320" (1024 buckets, since expectedKeys was omitted)Give each independent dataset its own namespace. Two stores sharing a namespace on the same Redis will write into the same buckets, and if they were built with different expectedKeys they will disagree about which bucket a key belongs to, which produces silent misses rather than errors.
The namespace is part of a top-level key name, so it does not count against the listpack value limit. Only field names (your keys) and values do. Length here costs you nothing but network bytes.
Do not repeat the namespace in your keys
This is the one namespace mistake with a real cost. Coming from plain Redis you probably write keys like user:f47ac10b-…, because a flat keyspace needs the prefix to stay organized. Here the prefix is already the bucket key, and the part you pass to set becomes a hash field, charged against the 64-byte hash-max-listpack-value limit.
const store = new PackHash(client, { namespace: "users", expectedKeys: 1_000_000 });
const id = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
store.set(`user:${id}`, v); // → HSET users:635 "user:f47ac10b-…" 41-byte field
store.set(id, v); // → HSET users:1342 "f47ac10b-…" 36-byte fieldBoth work, and both fit. The point is the budget: a bare UUID is 36 of the 64 bytes, so you have 28 to spare, and a prefix eats into them for nothing.
| Field | Bytes | Headroom |
|---|---|---|
<uuid> | 36 | 28 |
user:<uuid> | 41 | 23 |
user_profile:<uuid> | 49 | 15 |
cache:user_profile_v2:<uuid> | 58 | 6 |
Every field above still fits, so its bucket stays a listpack. But the last one is one naming convention change away from not fitting, and when it tips, nothing fails loudly: the bucket silently becomes a hashtable and starts costing what you adopted this library to avoid. Put the prefix in namespace, where it is free.
Validation: must be a non-empty string, or a TypeError is thrown.
maxListpackEntries
Type: number. Default: 512.
A mirror of your server's hash-max-listpack-entries. It is used only to size buckets. The library cannot read your redis.conf, so if you have changed the server setting, set this to match:
redis-cli config get hash-max-listpack-entriesLeave it alone if you have not touched the server default. Setting it higher than the server's actual value is the dangerous direction: buckets get sized for headroom that does not exist, and they silently get promoted to hashtable encoding, which is the exact cost this library exists to avoid.
Validation: must be a positive integer, or a TypeError is thrown.
The other listpack limit
hash-max-listpack-value (default 64 bytes) is a separate threshold that redis-packhash does not manage, and it applies to both the field name and the value. Your logical key is the field name, so:
- A key longer than 64 bytes promotes its bucket out of listpack.
- A value longer than 64 bytes does the same.
One oversized entry demotes the whole bucket, not just that entry. If your values are routinely larger than 64 bytes, bucketing will not keep you in listpack encoding and this library is not the right tool. Check with:
redis-cli object encoding users:0Bulk throughput
There is no option for this. mset and mget issue their commands strictly sequentially, awaiting each before sending the next, so a large batch costs one full round trip per entry.
Client-side auto-pipelining does not rescue this, which is worth stating plainly because it is the obvious thing to reach for. Both ioredis's enableAutoPipelining and node-redis's automatic batching work by grouping commands that are issued while an earlier command is still pending. Awaiting each reply guarantees there is never a second command in flight, so there is nothing to group. Turning it on changes nothing for these two helpers.
The lever is to issue the commands yourself, concurrently or in a real pipeline. bucketKeyFor gives you the hash key, and the rest is an ordinary HSET:
// ioredis: an explicit pipeline
const pipeline = client.pipeline();
for (const [key, value] of chunk) {
pipeline.hset(store.bucketKeyFor(key), key, value);
}
await pipeline.exec();
// node-redis: issue concurrently, and its batching groups them for you
await Promise.all(
chunk.map(([key, value]) => client.hSet(store.bucketKeyFor(key), key, value)),
);Work in chunks of a few thousand rather than firing a whole backfill at once, so memory and the server's input buffer stay bounded.
On Redis Cluster an explicit pipeline cannot span hash slots, and buckets are spread across slots by design. Use the concurrent form there, or your client's cluster-aware pipeline, and let each command route itself.
Sizing worked through
Suppose you are caching the status of 3 million orders, keyed by order UUID. Values are compact JSON. The order: prefix you would normally put on the key goes in namespace instead, so the field is the bare UUID.
import { PackHash, computeBuckets } from "redis-packhash";
computeBuckets({ expectedKeys: 3_000_000, maxListpackEntries: 512 });
//=> 7813
const orders = new PackHash(client, {
namespace: "orders",
expectedKeys: 3_000_000,
});
await orders.set(
"3d5e1a77-0c94-4f2b-8e61-7a9b0d4c2f18",
JSON.stringify({ status: "shipped", carrier: "ups" }),
);
// → HSET orders:3690 "3d5e1a77-0c94-4f2b-8e61-7a9b0d4c2f18" '{"status":"shipped","carrier":"ups"}'That gives 7,813 top-level Redis keys instead of 3,000,000, with about 384 fields in each. The field is 36 bytes and the value is 36, so both clear the 64-byte limit and every bucket stays a listpack. Verify on a populated instance:
redis-cli object encoding orders:0 # → listpack
redis-cli hlen orders:0 # → ~384
redis-cli dbsize # → ~7813, not 3000000If object encoding comes back hashtable, work through the causes in order: a key or value over 64 bytes, expectedKeys set well below reality, or maxListpackEntries set above what the server actually enforces.