---
title: 'Valkey with Node.js and TypeScript: 2026 Setup Guide'
description: 'Connect Valkey from Node.js and TypeScript with the iovalkey client: Docker setup, typed caching, pipelines, pub/sub, error handling, and graceful shutdown.'
url: https://nerdleveltech.com/valkey-nodejs-typescript-production-tutorial
language: en
date: '2026-06-16'
category: backend
tags:
  - valkey
  - node.js
  - typescript
  - iovalkey
  - redis
  - caching
  - docker
  - backend
markdown_url: https://nerdleveltech.com/valkey-nodejs-typescript-production-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/valkey-nodejs-typescript-production-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Valkey with Node.js and TypeScript: 2026 Setup Guide

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.3**[^iovalkey], the TypeScript-native, ioredis-compatible Valkey client. You will run **Valkey 8.1.8**[^releases] 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.3**[^ts], **tsx 4.22.4**[^tsx], **@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`/`GET` and 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.[^license] 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 API[^iovalkey] |
| **valkey-glide** | `@valkey/valkey-glide` | Rust core + Node bindings | Apache-2.0 | Greenfield apps wanting the official client built for reliability and HA[^glide] |
| **node-redis** | `redis` | JavaScript | MIT | Teams already on the official Redis client who want minimal change[^clients] |

`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.[^iovalkey] 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.[^iovalkey]

`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.[^glide] 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 newer[^iovalkey])
- **Docker** with Compose v2 (the `docker compose` command, 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:

```text
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:

```yaml
# 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:

```bash
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:

```bash
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.[^releases] 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.[^releases]

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

```bash
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`:

```json
{
  "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:

```typescript
// 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`:

```typescript
// 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.[^pipelining] Here is both, in `src/demo.ts`:

```typescript
// 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.[^pubsub] 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:

```typescript
  // 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:

```typescript
// 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:

```typescript
// 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:

```bash
npx tsx src/demo.ts
```

Expected output, end to end:

```text
[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:

```bash
npx tsc --noEmit
# (no output = success)
```

Finally, confirm you really are talking to Valkey and not just any RESP server:

```bash
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.

## FAQ

**How do I connect to Valkey from a Node.js app?** Install `iovalkey`, create `new Valkey({ host, port })`, and attach an `error` listener. The constructor connects automatically and emits `ready` when commands can run.

**Is Valkey a drop-in replacement for Redis in Node.js?** For the core data types and commands, yes — Valkey kept the Redis wire protocol from the 7.2.4 fork point, so `ioredis`, `node-redis`, and `iovalkey` all work. The exception is Redis Ltd.'s proprietary modules (RediSearch, RedisJSON, RedisTimeSeries): the plain `valkey/valkey` image does not load them, though Valkey ships open-source equivalents — `valkey-search` and `valkey-json` — in its separate `valkey-bundle` image.[^bundle]

**What is the difference between iovalkey and ioredis?** `iovalkey` is a friendly fork of `ioredis` with the same API; it is maintained under the Valkey organisation and written in TypeScript with bundled types. If you already use `ioredis`, the migration is essentially a dependency swap.[^iovalkey]

## 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](https://nerdleveltech.com/redis-caching-patterns-the-complete-guide-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](https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial).
- Compare Valkey pub/sub with a database-native alternative in [Postgres LISTEN/NOTIFY as a job queue](https://nerdleveltech.com/postgres-listen-notify-job-queue).

External references: the [iovalkey repository](https://github.com/valkey-io/iovalkey), the [Valkey client libraries page](https://valkey.io/clients/), and the [Valkey releases list](https://valkey.io/download/releases/).

## Related reads

- [Semantic Cache for LLM Calls with Redis Vector Sets (2026)](https://nerdleveltech.com/semantic-cache-llm-redis-vector-sets-nodejs-tutorial)

[^iovalkey]: 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).
[^releases]: 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`; the `8.1.8`, `8.1.8-alpine`, and `8.1.8-alpine3.23` tags (pushed 2026-06-02) are listed at https://hub.docker.com/r/valkey/valkey/tags (both accessed 16 June 2026).
[^ts]: TypeScript 6.0.3, latest stable per the npm registry, 16 June 2026. https://www.npmjs.com/package/typescript
[^tsx]: tsx 4.22.4, latest stable per the npm registry, 16 June 2026. https://www.npmjs.com/package/tsx
[^license]: 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
[^glide]: 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
[^clients]: Valkey Client Libraries — official directory listing GLIDE, node-redis, and community clients. https://valkey.io/clients/
[^pipelining]: Valkey — Pipelining batches multiple commands into a single round trip; transactions (MULTI/EXEC) execute a batch atomically. https://valkey.io/topics/pipelining/
[^pubsub]: Valkey — Pub/Sub. A connection subscribed to a channel cannot issue other commands. https://valkey.io/topics/pubsub/
[^bundle]: As of Valkey 8.1, JSON and search are available through the `valkey-bundle` image, which packages the open-source modules `valkey-json`, `valkey-search`, `valkey-bloom`, and `valkey-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/
