Getting started
redis-packhash shards your key→value pairs across a fixed set of Redis hashes so each hash stays in the memory-compact listpack encoding. You get a set/get/del/has API; it handles the bucketing.
Install
npm install redis-packhashpnpm add redis-packhashyarn add redis-packhashbun add redis-packhashRequires Node 22 or newer. Zero runtime dependencies.
Both module formats ship:
import { PackHash } from "redis-packhash"; // ESM / TypeScript
const { PackHash } = require("redis-packhash"); // CommonJSYou also need a Redis client. redis-packhash does not bundle one.
Minimal working example
A profile cache for an application with about a million users, keyed by user UUID:
import { PackHash } from "redis-packhash";
import Redis from "ioredis";
const store = new PackHash(new Redis(), {
namespace: "users",
expectedKeys: 1_000_000,
});
const id = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
await store.set(id, JSON.stringify({ name: "John Doe", plan: "pro" }));
const raw = await store.get(id);
const user = raw ? JSON.parse(raw) : null;
//=> { name: "John Doe", plan: "pro" }
await store.has(id); //=> true
await store.del(id); //=> true
await store.get(id); //=> nullThat is the whole surface for single keys. Three things to internalize before you go further:
- Values are strings.
setrejects with aTypeErrorif you hand it anything else. Serialize withJSON.stringifyand parse on the way back out. expectedKeysis effectively fixed once you have data. It determines the bucket count, and the bucket count determines where every key lands. See Configuration.- Pass the bare id, not a prefixed one. Your key is stored as a hash field, and field names are charged against
hash-max-listpack-value(64 bytes) exactly like values are. A UUID is already 36 of those bytes. The bucket key carries the namespace, so"f47ac10b-…"is right and"user:f47ac10b-…"spends five more bytes restating whatnamespace: "users"already says. See Configuration.
Both sides of the value limit have room here: the field is the 36-byte UUID and the value {"name":"John Doe","plan":"pro"} is 32 bytes, against the 64-byte hash-max-listpack-value ceiling that applies to each of them separately.
With node-redis
The constructor detects the client's command casing, so node-redis v4+ (hSet/hGet/hDel) works with no adapter of your own:
import { PackHash } from "redis-packhash";
import { createClient } from "redis";
const client = createClient();
await client.connect();
const store = new PackHash(client, { namespace: "users", expectedKeys: 1_000_000 });
await store.set("f47ac10b-58cc-4372-a567-0e02b2c3d479", "…");With any other client
Anything exposing hset, hget and hdel is a valid client. Each return value is passed through Promise.resolve, so the methods can be synchronous or async. If your client has a different shape, wrap it:
import { PackHash } from "redis-packhash";
const store = new PackHash({
hset: (key, field, value) => myClient.command("HSET", key, field, value),
hget: (key, field) => myClient.command("HGET", key, field),
hdel: (key, field) => myClient.command("HDEL", key, field),
});If a required method is missing, the constructor throws UnsupportedClientError immediately rather than failing on your first write.
Writing and reading in bulk
mset and mget take the loop off your hands. They are sequential: each command is awaited before the next is sent.
await store.mset([
[
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
JSON.stringify({ name: "Ada Lovelace", plan: "pro" }),
],
[
"9c858901-8a57-4791-81fe-4c455b099bc9",
JSON.stringify({ name: "Grace Hopper", plan: "free" }),
],
]);
const found = await store.mget([
"f47ac10b-58cc-4372-a567-0e02b2c3d479",
"9c858901-8a57-4791-81fe-4c455b099bc9",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8", // never cached
]);
found.get("6ba7b810-9dad-11d1-80b4-00c04fd430c8"); //=> nullmset iterates its input with for...of, so a generator is consumed one pair at a time and the batch never exists as an array. Here is a backfill of three million orders, keyed by order UUID:
// Its own store: this dataset is 3M rows, not the 1M the profile cache above
// was sized for. See the warning below.
const orders = new PackHash(client, {
namespace: "orders",
expectedKeys: 3_000_000,
});
type OrderRow = { id: string; status: string; carrier: string };
function* pairs(rows: Iterable<OrderRow>) {
for (const row of rows) {
yield [row.id, JSON.stringify({ status: row.status, carrier: row.carrier })] as const;
}
}
const rows: Iterable<OrderRow> = loadOrderRows(); // whatever your source is
await orders.mset(pairs(rows));That yields 7,813 buckets holding about 384 fields each. The field is a 36-byte UUID, and a status value like {"status":"shipped","carrier":"ups"} is 36 bytes, so entries of that shape stay well inside the 64-byte limit and every bucket stays a listpack.
Three million sequential round trips is slow, though, and client-side auto-pipelining will not help: mset never has two commands in flight for it to batch. mset is a convenience wrapper for small batches. For a backfill this size, drive the pipeline yourself. See Bulk throughput.
Size the store for its own dataset
expectedKeys is what makes the sizing work, and it is per store. Pushing those 3,000,000 orders through the profile cache above instead, which resolved to 2,605 buckets, would put roughly 1,150 fields in every bucket. That is past the 512-entry limit, so Redis would promote all 2,605 of them to hashtable encoding. Every write would still succeed. You would simply have paid for none of the memory saving.
mset takes a synchronous iterable
Iterable, not AsyncIterable. A database cursor or a stream cannot be passed straight in. Batch it: pull a few thousand rows, mset them, repeat.
Checking your sizing
computeBuckets answers "how many buckets would this dataset get?" without constructing a store:
import { computeBuckets } from "redis-packhash";
computeBuckets({ expectedKeys: 1_000_000, maxListpackEntries: 512 });
//=> 2605And on a live store, bucketKeyFor tells you exactly which Redis key holds a logical key, which is what you want when you are poking at things with redis-cli:
store.bucketKeyFor("f47ac10b-58cc-4372-a567-0e02b2c3d479");
//=> "users:1342"redis-cli hget users:1342 f47ac10b-58cc-4372-a567-0e02b2c3d479
redis-cli object encoding users:1342 # → "listpack" if sizing is holdingThat last command is the real check. If it reports hashtable, a bucket has outgrown the listpack limit. Configuration covers why that happens and what to do about it.
What this does not do
- No TTL. Each key is a hash field, and
EXPIREcannot target a field. Per-field expiry exists only on Redis 7.4+ (HEXPIRE) and is not exposed here. Use plain top-level keys when you need expiry. - No serialization. Strings in, strings out, verbatim.
- No key enumeration. There is no
keys()orscan(). If you need to walk the dataset, iterate the buckets yourself withHSCANover<namespace>:0…<namespace>:N-1, whereNisstore.buckets.
Next steps
- Configuration for
expectedKeys,namespaceandmaxListpackEntries. - API reference for every export and every thrown error.