Skip to content

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

By KVNestUpdated

GET use-cases:sessions-and-rate-limiting

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.

On this page

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

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      sessions: {
        driver: "redis",
        url: process.env.VALKEY_URL,
        base: "sessions",
        ttl: 60 * 60,
      },
    },
  },
});
// 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.

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.

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.

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.

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, where an agent such as Claude Code can create the instance for you.

Where KVNest falls short

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 and the EU managed Redis and Valkey comparison for the alternatives.

FAQ

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

All 3 sources
  1. [1]EXPIRE command (NX option), Valkey documentation (valkey.io), checked 5 Nov 2026.
  2. [2]SET command (NX, PX), Valkey documentation (valkey.io), checked 5 Nov 2026.
  3. [3]unstorage Redis driver (unstorage.unjs.io), checked 5 Nov 2026.

Try KVNest: free managed Redis or Valkey

A free Starter instance in Germany during the public preview, no card needed. Redis 8.10 or Valkey 9.1, your pick.