Valkey with Node.js and TypeScript: 2026 Setup Guide
June 16, 2026
To connect to Valkey from a Node.js and TypeScript app, install the iovalkey client, create a new Valkey({ host, port }) instance, and always attach an error listener so a dropped connection cannot crash your process. This guide builds that client end to end against a Dockerized Valkey.
TL;DR
This hands-on guide wires a production-ready Valkey client into a Node.js and TypeScript project using iovalkey 0.3.31, the TypeScript-native, ioredis-compatible Valkey client. You will run Valkey 8.1.82 locally with Docker Compose, build a typed client module with a retry strategy and the mandatory error listener, then exercise typed caching with TTLs, pipelines, atomic transactions, pub/sub, a health check, and graceful shutdown. Stack: iovalkey 0.3.3, TypeScript 6.0.33, tsx 4.22.44, @types/node 25.9.3 on Node.js 22. Every TypeScript example here was type-checked under strict mode, and the full demo ran end to end against a live Valkey 8.1.8 on 16 June 2026. Budget about 30–40 minutes.
What you'll learn
- How to pick a Valkey Node.js client: iovalkey vs ioredis vs valkey-glide
- How to run Valkey locally with Docker Compose, pinned and persistent
- How to build a production TypeScript client module with a retry strategy and the error listener that stops Valkey crashing your process
- How to do type-safe caching with
SET/GETand TTLs - How to batch commands with pipelines and run atomic transactions (
MULTI/EXEC) - How to use pub/sub on a dedicated connection
- How to add a health check and graceful shutdown, and troubleshoot common connection errors
Choosing a Valkey client: iovalkey vs ioredis vs valkey-glide
Valkey is the Linux Foundation fork of Redis, created after Redis Ltd. changed its license away from BSD in March 2024; Valkey started from Redis 7.2.4, the last BSD-licensed release.5 Because Valkey kept the same RESP wire protocol and command set, any Redis client works against Valkey — but in 2026 you have three sensible choices, and the right one depends on your existing code.
| Client | Package | Language | License | Best for |
|---|---|---|---|---|
| iovalkey | iovalkey | TypeScript | MIT | Teams already on ioredis — it is a friendly fork with the same API1 |
| valkey-glide | @valkey/valkey-glide | Rust core + Node bindings | Apache-2.0 | Greenfield apps wanting the official client built for reliability and HA6 |
| node-redis | redis | JavaScript | MIT | Teams already on the official Redis client who want minimal change7 |
iovalkey is a direct fork of ioredis taken just after a specific upstream commit, so its API mirrors ioredis: the same constructor, the same events, the same pipeline() and multi() builders.1 If you already use ioredis, migrating is usually a one-line dependency swap. It is written entirely in TypeScript and ships its own type declarations, so there is no separate @types package to install.1
valkey-glide is the official Valkey client. It uses a shared Rust core with language bindings and is designed around reliability and high availability, but its API differs from ioredis — for example, multi-key commands take arrays rather than spread arguments.6 That makes it a better fit for new code than for a drop-in migration. This guide uses iovalkey because it gives the cleanest TypeScript experience for the largest group of readers: anyone with existing ioredis muscle memory.
Prerequisites
- Node.js 22+ (iovalkey requires Node 18.12.0 or newer1)
- Docker with Compose v2 (the
docker composecommand, included in current Docker Desktop and Docker Engine) - A terminal and a code editor; basic familiarity with
async/await
Pin your versions exactly so this tutorial is reproducible:
iovalkey 0.3.3
typescript 6.0.3
tsx 4.22.4
@types/node 25.9.3
Valkey image valkey/valkey:8.1.8-alpine
Step 1 — Run Valkey locally with Docker Compose
Run Valkey locally so your app has something to connect to. Create a project folder and a docker-compose.yml that pins the image and turns on persistence:
# docker-compose.yml
services:
valkey:
image: valkey/valkey:8.1.8-alpine
container_name: valkey
ports:
- "6379:6379"
# Enable both snapshotting (RDB) and the append-only file (AOF).
command: ["valkey-server", "--save", "60", "1", "--appendonly", "yes"]
volumes:
- valkey-data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
valkey-data:
Start it and confirm it is healthy:
docker compose up -d
docker compose ps
You should see the valkey service with a running (healthy) status once the health check passes. The official valkey/valkey image ships the valkey-cli binary, so the health check just runs valkey-cli ping inside the container. Test it directly from your host:
docker compose exec valkey valkey-cli ping
# PONG
valkey/valkey:8.1.8-alpine is the latest patch of the 8.1 line, released 2 June 2026.2 Valkey 9.1.0 (19 May 2026) is the newest release; the steps in this guide are identical against it — only the image tag changes.2
Step 2 — Connect from TypeScript with a production client
Connecting takes three lines, but a production connection needs a retry strategy and — critically — an error listener, or a single dropped connection will crash your Node process. Initialise the project:
npm init -y
npm pkg set type=module
npm install iovalkey@0.3.3
npm install -D typescript@6.0.3 tsx@4.22.4 @types/node@25.9.3
Create a tsconfig.json. Using moduleResolution: "bundler" and running with tsx avoids the CommonJS-interop friction you hit when importing iovalkey (a CommonJS package) under nodenext:
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
Now create src/valkey.ts, the single place your whole app imports its client from:
// src/valkey.ts
import Valkey, { type RedisOptions } from "iovalkey";
const options: RedisOptions = {
host: process.env.VALKEY_HOST ?? "127.0.0.1",
port: Number(process.env.VALKEY_PORT ?? 6379),
password: process.env.VALKEY_PASSWORD || undefined,
// Fail a command after 3 attempts instead of queueing it forever.
maxRetriesPerRequest: 3,
// Custom backoff: 50ms, 100ms, 150ms ... capped at 2s. Returning a number
// schedules the next reconnect; returning null stops retrying entirely.
retryStrategy(times: number) {
return Math.min(times * 50, 2000);
},
};
export const valkey = new Valkey(options);
// REQUIRED. iovalkey is an EventEmitter; an emitted "error" with no listener
// is an unhandled error event, and Node terminates the whole process.
valkey.on("error", (err: Error) => {
console.error("[valkey] client error:", err.message);
});
valkey.on("connect", () => console.log("[valkey] TCP connected"));
valkey.on("ready", () => console.log("[valkey] ready to accept commands"));
valkey.on("reconnecting", (ms: number) =>
console.log(`[valkey] reconnecting in ${ms}ms`),
);
The constructor connects immediately (there is no await), then emits connect when the socket opens and ready when Valkey is prepared to take commands. The error listener is not optional politeness — it is the difference between a transient network blip being logged and your service hard-crashing. We will reproduce that crash in Troubleshooting so you can recognise it.
Step 3 — Type-safe caching with SET, GET, and TTLs
A very common Valkey use case is caching, and it is where TypeScript pays off: wrap get/set in typed helpers so callers never hand-roll JSON. Create src/cache.ts:
// src/cache.ts
import { valkey } from "./valkey";
// Store any serialisable value with an explicit TTL in seconds.
export async function setJSON<T>(
key: string,
value: T,
ttlSeconds: number,
): Promise<void> {
await valkey.set(key, JSON.stringify(value), "EX", ttlSeconds);
}
// Returns null on a cache miss; otherwise the parsed, typed value.
export async function getJSON<T>(key: string): Promise<T | null> {
const raw = await valkey.get(key);
return raw === null ? null : (JSON.parse(raw) as T);
}
The "EX" argument maps directly to Valkey's SET key value EX seconds form, so the key is stored and given a time-to-live in a single round trip. Because get returns string | null, the helper makes the cache-miss case explicit in the type system: callers must handle null.
Step 4 — Pipelines and atomic transactions
When you need to run several commands, do not await them one at a time — each await is a separate network round trip. Pipelines send a batch of commands in one round trip; transactions (MULTI/EXEC) additionally run the batch atomically with no other client's commands interleaved.8 Here is both, in src/demo.ts:
// src/demo.ts
import { valkey } from "./valkey";
import { setJSON, getJSON } from "./cache";
interface Session {
userId: string;
role: "admin" | "member";
}
async function main() {
// Typed round-trip with a 60-second TTL.
await setJSON<Session>("session:42", { userId: "42", role: "admin" }, 60);
const session = await getJSON<Session>("session:42");
console.log("session:", session);
console.log("ttl:", await valkey.ttl("session:42"));
// Pipeline: four commands, one round trip. exec() resolves to an array of
// [error, result] tuples, in command order.
const results = await valkey
.pipeline()
.set("page:home:views", 0)
.incr("page:home:views")
.incrby("page:home:views", 9)
.get("page:home:views")
.exec();
console.log("pipeline replies:", results?.map(([err, val]) => err ?? val));
// Transaction: same builder API, but MULTI/EXEC make it atomic.
const tx = await valkey
.multi()
.set("balance", 100)
.decrby("balance", 30)
.get("balance")
.exec();
console.log("tx replies:", tx?.map(([err, val]) => err ?? val));
}
Both pipeline() and multi() return a builder you chain commands onto, then call .exec(). The reply is an array of [error, result] pairs — checking the per-command error slot is how you detect that, say, an INCR failed because the value was not an integer. When you run this in the verification step, the pipeline replies are ['OK', 1, 10, '10'] (set, then increment to 1, then add 9 to reach 10, then read it back) and the transaction replies are ['OK', 70, '70'].
Step 5 — Pub/sub on a dedicated connection
Pub/sub lets one part of your system broadcast a message to many subscribers — useful for cache invalidation or fan-out notifications.9 The one rule that trips people up: a connection in subscriber mode cannot run normal commands, so you need a second connection. iovalkey's duplicate() clones your configured client into a fresh connection for exactly this. Add this to main() in src/demo.ts, before the function ends:
// duplicate() clones the connection config into a NEW connection.
const subscriber = valkey.duplicate();
await subscriber.subscribe("notifications");
// Resolve a promise the first time a message arrives.
const got = new Promise<string>((resolve) => {
subscriber.on("message", (_channel: string, message: string) =>
resolve(message),
);
});
// Publish on the MAIN connection; the subscriber receives it.
await valkey.publish("notifications", "deploy finished");
console.log("received pub/sub message:", await got);
await subscriber.quit();
}
main()
.then(() => valkey.quit())
.then(() => console.log("done, connection closed cleanly"))
.catch(async (err) => {
console.error("demo failed:", err);
await valkey.quit();
process.exit(1);
});
Publishing happens on the original valkey connection while the duplicated subscriber connection listens; the message event fires with the channel name and payload. Note that each connection is closed with its own quit().
Step 6 — Health checks and graceful shutdown
In production you need two more things: a way for your orchestrator to know the client is alive, and a clean shutdown so in-flight commands finish before the process exits. For a health check, a PING is enough — it returns "PONG" and proves the round trip works:
// src/health.ts
import { valkey } from "./valkey";
export async function checkValkey(): Promise<boolean> {
try {
return (await valkey.ping()) === "PONG";
} catch {
return false;
}
}
For shutdown, prefer quit() over disconnect(). quit() sends a QUIT command and waits for pending replies before closing the socket, so you do not abandon commands mid-flight; disconnect() severs the socket immediately. Wire it to the signals your platform sends:
// src/shutdown.ts
import { valkey } from "./valkey";
async function shutdown(signal: string) {
console.log(`[valkey] ${signal} received, closing connection...`);
await valkey.quit();
process.exit(0);
}
process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
Verification
Make sure Valkey is running (docker compose up -d), then run the demo with tsx — no build step needed:
npx tsx src/demo.ts
Expected output, end to end:
[valkey] TCP connected
[valkey] ready to accept commands
session: { userId: '42', role: 'admin' }
ttl: 60
pipeline replies: [ 'OK', 1, 10, '10' ]
tx replies: [ 'OK', 70, '70' ]
received pub/sub message: deploy finished
done, connection closed cleanly
Type-check the whole project to confirm the strict types hold:
npx tsc --noEmit
# (no output = success)
Finally, confirm you really are talking to Valkey and not just any RESP server:
docker compose exec valkey valkey-cli info server | grep -E 'redis_version|valkey_version'
# redis_version:7.2.4 <- compatibility shim Valkey reports
# valkey_version:8.1.8 <- the real engine
The dual version line is expected: Valkey advertises a redis_version of 7.2.4 (the fork point) for client compatibility while reporting its true valkey_version.
Troubleshooting
MaxRetriesPerRequestError: Reached the max retries per request limit (which is 3). Your command could not reach Valkey within maxRetriesPerRequest attempts. Usually Valkey is not running or the host/port is wrong. Confirm docker compose ps shows healthy and that VALKEY_PORT matches the published port (6379 here).
The process exits and you see [ioredis] Unhandled error event: ... ECONNREFUSED. You forgot the error listener from Step 2. Because iovalkey is an EventEmitter, an emitted error with no listener is an unhandled error event and Node terminates the process. (The warning is prefixed [ioredis] because iovalkey is a fork of ioredis.) The fix is always valkey.on("error", ...).
This expression is not constructable on new Valkey(...) during tsc. This appears if you compile under "module": "nodenext", because iovalkey is published as CommonJS and its default import is not seen as constructable. Use the "moduleResolution": "bundler" config from Step 2 and run with tsx, or import the named class instead of the default export.
Subscriber connection "can't execute commands" / replies hang. A connection in subscriber mode only accepts subscribe/unsubscribe and receives messages — it cannot run GET, SET, etc. Use valkey.duplicate() for the subscriber (Step 5) and keep your main connection for regular commands.
Reads succeed but data vanishes after a restart. Persistence is off. The docker-compose.yml in Step 1 enables both RDB snapshots (--save 60 1) and the append-only file (--appendonly yes) and mounts a named volume at /data; without the volume, the container's data is lost when it is recreated.
Next steps and further reading
You now have a typed, production-shaped Valkey client with retries, pub/sub, health checks, and clean shutdown. From here:
- Layer real caching strategies on top of
setJSON/getJSON— see our guide to Redis caching patterns for scalable systems, which applies directly to Valkey. - Build a sliding-window limiter on the same connection with our API rate limiting with Redis tutorial.
- Compare Valkey pub/sub with a database-native alternative in Postgres LISTEN/NOTIFY as a job queue.
External references: the iovalkey repository, the Valkey client libraries page, and the Valkey releases list.
Footnotes
-
iovalkey — "A robust, performance-focused and full-featured Valkey client for Node.js. This is a friendly fork of ioredis." Supports Valkey >= 7.0.0; engines
node >= 18.12.0; 100% TypeScript with bundled type declarations; MIT licensed. Version 0.3.3 per npm. https://github.com/valkey-io/iovalkey and https://www.npmjs.com/package/iovalkey (accessed 16 June 2026). ↩ ↩2 ↩3 ↩4 ↩5 ↩6 -
Valkey Releases — 8.1.8 released 2026-06-02; 9.1.0 released 2026-05-19; 9.0.4 released 2026-05-06. https://valkey.io/download/releases/ Official images are published as
valkey/valkey; the8.1.8,8.1.8-alpine, and8.1.8-alpine3.23tags (pushed 2026-06-02) are listed at https://hub.docker.com/r/valkey/valkey/tags (both accessed 16 June 2026). ↩ ↩2 ↩3 -
TypeScript 6.0.3, latest stable per the npm registry, 16 June 2026. https://www.npmjs.com/package/typescript ↩
-
tsx 4.22.4, latest stable per the npm registry, 16 June 2026. https://www.npmjs.com/package/tsx ↩
-
Redis Ltd. changed the Redis license from BSD-3 to dual source-available terms (RSALv2 / SSPLv1) on 20 March 2024; the Linux Foundation launched Valkey days later as a fork of Redis 7.2.4, the last BSD-licensed release, backed by AWS, Google Cloud, and Oracle. https://valkey.io/ and https://www.devclass.com/development/2025/04/01/one-year-ago-redis-changed-its-license-and-lost-most-of-its-external-contributors/1624450 ↩
-
Valkey GLIDE — the official Valkey client library, built on a Rust core with multi-language bindings (Node.js support added in GLIDE v1.1, September 2024), Apache-2.0 licensed. Its API differs from ioredis (e.g., multi-key commands take arrays). https://github.com/valkey-io/valkey-glide ↩ ↩2
-
Valkey Client Libraries — official directory listing GLIDE, node-redis, and community clients. https://valkey.io/clients/ ↩
-
Valkey — Pipelining batches multiple commands into a single round trip; transactions (MULTI/EXEC) execute a batch atomically. https://valkey.io/topics/pipelining/ ↩
-
Valkey — Pub/Sub. A connection subscribed to a channel cannot issue other commands. https://valkey.io/topics/pubsub/ ↩
-
As of Valkey 8.1, JSON and search are available through the
valkey-bundleimage, which packages the open-source modulesvalkey-json,valkey-search,valkey-bloom, andvalkey-ldap. Redis Ltd.'s proprietary RediSearch/RedisJSON modules are not redistributable with Valkey, so these community equivalents are used instead. https://valkey.io/blog/introducing-enhanced-json-capabilities-in-valkey/ ↩