Connecting to Redis from Node.js
Everything you have typed so far has gone straight to redis-cli, but real applications don’t talk to Redis that way. A Node.js app uses a client library — an npm package that opens a TCP connection to the Redis server, translates JavaScript function calls into the Redis wire protocol, and parses the replies back into JavaScript values. Getting this connection set up correctly, and reusing it properly, is one of the most common sources of bugs in production Redis usage: connection storms, unhandled errors that crash the process, and race conditions from doing in application code what Redis could do atomically in one command.
Overview / How it works
Redis speaks a simple text-based wire protocol called RESP (REdis Serialization Protocol). redis-cli is just one RESP client among many; a Node.js client library is another. When you call a method like client.set('key', 'value'), the library serializes that call into a RESP array of bulk strings, writes it to the open TCP socket, and waits for the reply bytes to come back, which it parses into a JS value (a string, a number, null, an array, and so on) and resolves a Promise with it.
The two dominant Node.js Redis clients are redis (the official client, sometimes called node-redis) and ioredis. Both work well; this lesson uses the official redis package (v4+), which is fully Promise-based. Conceptually every example here applies to ioredis too — only the exact method names differ slightly.
A client instance manages one underlying connection (or a small pool, depending on configuration) and a queue of in-flight commands. Because Redis itself is single-threaded — it executes one command at a time to completion before starting the next — every individual command your app sends is atomic on the server side. But the ordering and batching of commands from your Node process is the client library’s job: commands issued on the same client are sent in the order you call them, and the client resolves each Promise as its matching reply arrives.
Connecting is asynchronous. You construct the client object synchronously (this does not touch the network), then call an async connect() method that performs the TCP handshake and any authentication (password or ACL username/password) before the connection is usable. Attempting to send a command before connect() resolves — or after the connection has dropped — is a common source of runtime errors, covered below.
Syntax
The general shape of connecting with the official redis package looks like this:
import { createClient } from 'redis';
const client = createClient({
url: 'redis://[[username][:password]@]host:port[/db-number]',
socket: {
reconnectStrategy: (retries) => Math.min(retries * 100, 3000)
}
});
client.on('error', (err) => console.error('Redis Client Error', err));
await client.connect();
| Option | Meaning |
|---|---|
url |
Connection string: scheme, optional credentials, host, port, and optional database index. |
socket.host / socket.port |
Alternative to url for specifying the server directly. |
password |
Auth password if not embedded in the URL (required if the server has requirepass or ACLs enabled). |
database |
Logical database index to SELECT after connecting (default 0). |
socket.reconnectStrategy |
Function controlling reconnect backoff after the connection drops. |
socket.tls |
Set to true when connecting to a managed Redis provider that requires TLS. |
Examples
The examples below show the Node.js side, and then the equivalent redis-cli commands so you can see exactly what state ends up in the keyspace — the JavaScript client is just a different way of sending the same commands.
Example 1: connect, set, get, quit
import { createClient } from 'redis';
const client = createClient({
url: 'redis://localhost:6379'
});
client.on('error', (err) => console.error('Redis Client Error', err));
await client.connect();
await client.set('user:1001:name', 'Ada');
const name = await client.get('user:1001:name');
console.log(name);
await client.quit();
Output:
Ada
The same effect at the redis-cli prompt looks like this, and confirms that no TTL was set on the key (a plain SET never adds one):
SET user:1001:name "Ada"
GET user:1001:name
TTL user:1001:name
Output:
OK
"Ada"
(integer) -1
Example 2: connection URL from the environment, with reconnect handling
Hardcoding localhost:6379 only works on your laptop. Real apps read the connection string from an environment variable, and should log connection lifecycle events instead of silently succeeding or crashing:
import { createClient } from 'redis';
const client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: {
reconnectStrategy: (retries) => Math.min(retries * 100, 3000)
}
});
client.on('error', (err) => console.error('Redis connection error:', err.message));
client.on('ready', () => console.log('Redis client ready'));
await client.connect();
Output:
Redis client ready
The reconnectStrategy function is called after every dropped connection with the retry count so far; returning a number of milliseconds tells the client how long to wait before trying again (here, capped at 3 seconds). Without an error listener, a connection error emitted by the client can crash an otherwise healthy Node process with an unhandled error event.
Example 3: a cache-aside read using a shared client
The most common real-world use of a Redis client in application code is the cache-aside pattern: check the cache first, and only fall back to the real data source (a database, an API) on a miss, storing the result with a TTL so it self-expires:
async function getProductPrice(productId) {
const cacheKey = `product:${productId}:price`;
const cached = await client.get(cacheKey);
if (cached !== null) {
return parseFloat(cached);
}
const price = await fetchPriceFromDatabase(productId);
await client.set(cacheKey, price.toString(), { EX: 60 });
return price;
}
On the first call for a given product, client.get returns null, so the function fetches from the database and stores the result with EX: 60 (a 60-second TTL). Every call within the next 60 seconds hits the cache and skips the database entirely. The equivalent commands at the prompt:
SETEX product:42:price 60 "19.99"
GET product:42:price
TTL product:42:price
Output:
OK
"19.99"
(integer) 60
How it works step by step
- Your code calls
createClient(options). This only builds the client object in memory — no network activity happens yet. await client.connect()opens a TCP socket to the host and port, then, if a password or ACL user is configured, performs anAUTH(orHELLOwith credentials) exchange before the connection is marked usable.- When you call
client.set(key, value), the library serializes it into the RESP command*3\r\n$3\r\nSET\r\n...(an array of bulk strings) and writes those bytes to the socket, and immediately returns a pending Promise. - Redis’s single event loop picks the command off its input buffer, executes it atomically against the keyspace, and writes the RESP reply back on the same socket.
- The client reads the reply bytes, parses them according to RESP’s type prefixes (
+simple string,:integer,$bulk string,*array,-error), and resolves (or rejects, for an error reply) the Promise your code is awaiting. - Because commands on one client are written to the socket in call order, and Redis processes them in the order it receives them, replies come back in the same order — your Promises always resolve to the correct matching reply even though several may be in flight.
Common Mistakes
Mistake 1: creating a new client per request. Opening a fresh TCP connection (and re-authenticating) on every incoming HTTP request is slow and can exhaust the server’s connection limit under load:
// BAD: opens a brand-new connection on every request
app.get('/user/:id', async (req, res) => {
const client = createClient();
await client.connect();
const name = await client.get(`user:${req.params.id}:name`);
res.json({ name });
});
Create the client once at application startup and reuse it for every request:
// GOOD: one client created once, reused for every request
const client = createClient({ url: process.env.REDIS_URL });
client.on('error', (err) => console.error('Redis error', err));
await client.connect();
app.get('/user/:id', async (req, res) => {
const name = await client.get(`user:${req.params.id}:name`);
res.json({ name });
});
Mistake 2: sending commands before the connection is ready. createClient() does not connect for you — forgetting the await client.connect() step (or calling a command from code that runs before it resolves) means the command is sent on a socket that isn’t open yet, and the client rejects it rather than silently queuing it forever. Always await connect() once at startup before any route or job can reach the client.
Mistake 3: a non-atomic read-modify-write instead of an atomic command. A tempting way to implement a hit counter in application code is to GET the current value, add one in JavaScript, then SET it back. Under concurrent requests this is a race: two requests can both read the same value before either writes, and one increment is lost. Because Redis is single-threaded, an atomic command like INCR never has this problem — the read-and-write happens as one indivisible server-side step:
SET page:home:views 10
INCR page:home:views
INCR page:home:views
GET page:home:views
Output:
OK
(integer) 11
(integer) 12
"12"
In application code that means calling client.incr('page:home:views') instead of client.get followed by client.set — the same rule that applies to raw redis-cli usage applies to client-library code, since the client is only ever a thin wrapper over the same commands.
Best Practices
- Create one client instance per process at startup, and reuse it everywhere — do not create a new client inside a request handler.
- Always attach an
errorlistener before or immediately after callingconnect(); an unhandled client error can crash the Node process. - Read the connection URL, password, and TLS settings from environment variables, never hardcode credentials.
- Prefer atomic commands (
INCR,SETNX,SET ... NX) over a client-side GET-then-SET whenever multiple requests could touch the same key concurrently. - Set a TTL on any cache key you write from application code (
SET key value EX seconds, orSETEX) so a bug or a stale cache doesn’t grow the keyspace forever. - Close the client gracefully with
client.quit()during application shutdown so in-flight commands finish and the socket closes cleanly. - Use a
reconnectStrategywith backoff rather than letting a dropped connection retry instantly and hammer the server.
Practice Exercises
- Write a small Node.js script using
createClientthat connects, setssession:demo:tokento a random string with a 30-second TTL, reads it back withGET, prints the remaining TTL, and then callsclient.quit(). Confirm withredis-clithat the key really does expire after 30 seconds. - Take the "bad" example from Common Mistakes (a new client per request) and rewrite it as an Express app with a single module-level client created at startup, including an
errorlistener. - Implement a
getOrSetCache(key, ttlSeconds, computeFn)helper that checks the cache, and on a miss callscomputeFn(), stores the result with the given TTL, and returns it — then call it twice in a row and verify withredis-cli TTLthat the second call was served from cache without a new write.
Summary
- A Node.js app talks to Redis through a client library (such as the official
redispackage) that translates JS calls into the RESP wire protocol over a TCP connection. createClient()only builds the object; you mustawait client.connect()before sending commands.- Commands sent from the same client are ordered, and each one is atomic on the Redis server because Redis is single-threaded.
- Create and reuse a single client instance across your application instead of opening a new connection per request.
- Always attach an
errorevent handler to avoid unhandled errors crashing the process. - Prefer atomic Redis commands (like
INCR) over a client-side GET-then-SET to avoid race conditions. - Always set a TTL on cache keys written from application code to avoid unbounded memory growth.
