# Sessions, rate limiting and stampede-safe caching on Valkey or Redis

> Store each session under its own key with a TTL, rate-limit with INCR plus EXPIRE ... NX per client and window, and fill caches under a SET ... NX PX lock. On KVNest's free Starter plan, set the eviction policy to noeviction first, or the cache policy can silently drop sessions.

Updated 2026-11-05. By KVNest.

**Give every session its own key and a TTL, count requests with `INCR` and `EXPIRE ... NX`, and guard cache refills with `SET ... NX PX`.** Each is one or two commands and works the same on Valkey and on Redis. Everything below is Node.js 18 or later and reads `VALKEY_URL`, the `rediss://` URL from your instance page.

**Before you store sessions on Starter:** its eviction policy is `allkeys-lru`, which deletes the least recently used keys under memory pressure, sessions included. Set it to `noeviction` on the instance's Settings tab or with the `configure_instance` tool; changing it restarts the instance. With `noeviction`, writes fail once the 192 MB of data is full, so catch the out-of-memory error. Starter also restarts empty, because nothing is written to disk.

## Connect once

The [Node.js guide](https://kvnest.com/docs/nodejs) has the full client setup. Open one client per process, not per request. KVNest closes idle connections after five minutes, and `iovalkey` and `redis` both reconnect on their own.

## Sessions

### Nuxt and Nitro

Nitro's storage layer can mount the unstorage Redis driver, which needs `ioredis` installed (`npm install ioredis`). The driver's `ttl` option (seconds) expires each key, and writing an item again resets it, which gives you a sliding expiry.

```ts
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      sessions: {
        driver: "redis",
        url: process.env.VALKEY_URL,
        base: "sessions",
        ttl: 60 * 60,
      },
    },
  },
});
```

```ts
// server/utils/session.ts
import { randomUUID } from "node:crypto";
import type { H3Event } from "h3";

export async function useAppSession(event: H3Event) {
  const store = useStorage("sessions");
  let id = getCookie(event, "sid");
  let data = id ? await store.getItem<Record<string, unknown>>(id) : null;
  if (!id || !data) {
    id = randomUUID();
    data = {};
  }
  setCookie(event, "sid", id, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 60 * 60 });
  return {
    data,
    save: () => store.setItem(id!, data),
    destroy: () => store.removeItem(id!),
  };
}
```

### Express

`connect-redis` stores each session as a JSON string under `sess:<id>`. Pass `ttl` so the store expires keys, and `rolling: true` to renew the cookie on each response.

```js
import express from "express";
import session from "express-session";
import { RedisStore } from "connect-redis";
import { createClient } from "redis";

const client = createClient({ url: process.env.VALKEY_URL });
client.on("error", (err) => console.error("Redis client error", err));
await client.connect();

const app = express();
app.set("trust proxy", 1); // behind a proxy that terminates TLS
app.use(session({
  store: new RedisStore({ client, prefix: "sess:", ttl: 3600 }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: { secure: true, httpOnly: true, sameSite: "lax", maxAge: 3600 * 1000 },
}));

app.get("/", (req, res) => {
  req.session.views = (req.session.views ?? 0) + 1;
  res.send("Views: " + req.session.views);
});

app.listen(3000);
```

## Rate limiting

### Fixed window

`INCR` creates the key at 1 on the first request. `EXPIRE ... NX` sets the TTL only when none exists, so later requests do not extend the window. Both run in one transaction.

```js
import Valkey from "iovalkey";

const client = new Valkey(process.env.VALKEY_URL);

export async function fixedWindow(id, limit, windowSec) {
  const key = `rl:${id}`;
  const results = await client.multi().incr(key).expire(key, windowSec, "NX").exec();
  const count = results[0][1];
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

// await fixedWindow(`login:${ip}`, 5, 60)
```

A client can send `limit` requests at the end of one window and `limit` more at the start of the next.

### Sliding window

A sorted set holds one member per request, scored by timestamp. Trim old entries, add this request, count. Rejected requests still count, so a client that keeps hammering stays blocked.

```js
import { randomUUID } from "node:crypto";

export async function slidingWindow(id, limit, windowMs) {
  const key = `rl:sw:${id}`;
  const now = Date.now();
  const results = await client
    .multi()
    .zremrangebyscore(key, 0, now - windowMs)
    .zadd(key, now, `${now}-${randomUUID()}`)
    .zcard(key)
    .pexpire(key, windowMs)
    .exec();
  return { allowed: results[2][1] <= limit };
}
```

Memory is one entry per request in the window, so use it for low limits such as login attempts, not for 10,000 requests a minute.

## Stampede-safe caching

When a hot key expires, many requests recompute it at once. Let one caller take a lock with `SET ... NX PX`; the others poll. The lock expires by itself if its owner crashes, and a token keeps one caller from releasing another's lock.

```js
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const unlock = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0`;

export async function cached(key, ttlMs, compute) {
  for (let attempt = 0; attempt < 50; attempt++) {
    const hit = await client.get(key);
    if (hit !== null) return JSON.parse(hit);
    const token = randomUUID();
    if (await client.set(`lock:${key}`, token, "PX", 10_000, "NX") === "OK") {
      try {
        const value = await compute();
        await client.set(key, JSON.stringify(value), "PX", ttlMs);
        return value;
      } finally {
        await client.eval(unlock, 1, `lock:${key}`, token);
      }
    }
    await sleep(100);
  }
  throw new Error("cache fill timed out");
}
```

A cache can use Starter's default `allkeys-lru`. A shared instance for sessions and caches cannot have both policies, so use one instance for each.

## Lock it down

Create a read-write user limited to the key patterns `sess:*`, `sessions:*` and `rl:*`, and give the app that user's URL instead of the default one. If your host has fixed egress IPs, add them as an IP allowlist. Both are on the instance page or in the [MCP tools](https://kvnest.com/docs/mcp), where an agent such as Claude Code can create the instance for you.

## Where KVNest falls short

- **No persistence on Starter.** A restart or maintenance logs everyone out; there are no backups.
- **Single node.** No failover, and no SLA during the preview.
- **100 clients on Starter.** Serverless functions that open a connection per invocation can run out; reuse the client.

For sessions you cannot lose, wait for a persistent plan.

Starter is free during the preview, with 256 MiB of memory, and 192 MB of that is usable. It runs on Hetzner in Germany. See [pricing](https://kvnest.com/pricing) and the [EU managed Redis and Valkey comparison](https://kvnest.com/compare/eu-managed-redis) for the alternatives.

## Questions

### Can I store sessions on a free managed Redis or Valkey instance?

Yes, with two caveats on KVNest Starter. Switch the eviction policy from allkeys-lru to noeviction so sessions are not evicted under memory pressure, and accept that Starter writes nothing to disk, so a restart logs everyone out.

### Fixed window or sliding window?

A fixed window is two commands and fine for login and signup limits, but a client can send up to twice the limit across a window boundary. A sorted-set sliding window is fairer and costs one entry per request.

### Does this work on Vercel or Netlify?

On serverless functions that run on the Node.js runtime, yes: open the connection at module scope and use a rediss:// URL. Edge runtimes cannot open raw TCP connections, so these clients do not run there.

### How many sessions fit in 256 MiB?

Starter allows 192 MB of data. At about 1 KB per session that is roughly 190,000 sessions before other keys; measure your own average with MEMORY USAGE on a real session key.

## Sources

- [EXPIRE command (NX option), Valkey documentation](https://valkey.io/commands/expire/), checked 2026-11-05
- [SET command (NX, PX), Valkey documentation](https://valkey.io/commands/set/), checked 2026-11-05
- [unstorage Redis driver](https://unstorage.unjs.io/drivers/redis), checked 2026-11-05
