Back to Blog
Tutorial12 min read2026-08-01

Session Management and Caching with Fastify and the KV Store

Deploy a Fastify API on PandaStack with a managed Redis instance for session storage and response caching, using KV_URL for native clients and KV_REST_API for HTTP-based access.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

APIs that track user sessions or cache expensive queries need a fast key-value store. Redis is the standard choice, but running it yourself means managing persistence, failover, and memory limits. A managed Redis instance removes that work and gives you a connection string you can drop into your app.

PandaStack's KV store is managed Redis, provisioned independently of SQL databases and linked to apps via environment variables. When you link a KV instance to a Fastify app, PandaStack injects KV_URL (for native Redis clients) and KV_REST_API_URL + KV_REST_API_TOKEN (for HTTP-based access).

Build a Fastify API with session storage

Create a basic Fastify app that stores user sessions in Redis. File server.js:

const fastify = require('fastify')({ logger: true });
const Redis = require('ioredis');

const redis = new Redis(process.env.KV_URL);

fastify.post('/login', async (request, reply) => {
  const { username } = request.body;
  const sessionId = `session:${Date.now()}:${Math.random()}`;

  await redis.setex(sessionId, 3600, JSON.stringify({ username, createdAt: Date.now() }));

  reply.send({ sessionId });
});

fastify.get('/session/:id', async (request, reply) => {
  const { id } = request.params;
  const session = await redis.get(id);

  if (!session) {
    return reply.status(404).send({ error: 'Session not found or expired' });
  }

  reply.send(JSON.parse(session));
});

const PORT = process.env.PORT || 3000;
fastify.listen({ port: PORT, host: '0.0.0.0' }, (err) => {
  if (err) {
    fastify.log.error(err);
    process.exit(1);
  }
});

Add dependencies in package.json:

{
  "name": "fastify-kv",
  "version": "1.0.0",
  "dependencies": {
    "fastify": "^4.25.0",
    "ioredis": "^5.3.0"
  },
  "scripts": {
    "start": "node server.js"
  }
}

This app creates a session on /login (storing the username and creation timestamp in Redis with a 1-hour TTL) and retrieves it on /session/:id. Sessions expire automatically after 3600 seconds.

Provision a KV store and link it to the app

Create a KV instance in the dashboard under KV Store → Create. Name it sessions-cache and provision.

Create a Fastify project on PandaStack:

panda projects create \
  --name fastify-api \
  --repo github.com/yourname/fastify-kv \
  --branch main \
  --type container

Link the KV store:

panda projects link-kv <project-id> <kv-id>

PandaStack injects KV_URL in the format redis://user:password@kv.internal.pandastack.io:6379. The ioredis client reads this and connects with TLS automatically.

Deploy the app:

panda projects deploy <project-id>

Test the session flow:

curl -X POST https://fastify-api-abc123.pandastack.app/login \
  -H "Content-Type: application/json" \
  -d '{"username": "alice"}'

Response:

{"sessionId": "session:1672531200000:0.123456"}

Retrieve the session:

curl https://fastify-api-abc123.pandastack.app/session/session:1672531200000:0.123456

Response:

{"username": "alice", "createdAt": 1672531200000}

Wait 61 minutes and try again. The session is gone (404) because the TTL expired.

Cache expensive queries with a decorator

Add a caching layer for slow database queries. Define a Fastify decorator that checks Redis before hitting the database:

fastify.decorate('withCache', async function (key, ttl, fetcher) {
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }

  const data = await fetcher();
  await redis.setex(key, ttl, JSON.stringify(data));
  return data;
});

Use it in a route:

fastify.get('/users', async (request, reply) => {
  const users = await fastify.withCache('users:list', 300, async () => {
    // Simulate a slow database query
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' }
    ];
  });

  reply.send(users);
});

The first request runs the fetcher function (the slow query), stores the result in Redis with a 5-minute TTL, and returns it. Subsequent requests within 5 minutes read from cache and skip the database.

This pattern is useful for leaderboard queries, aggregated stats, or any data that updates infrequently and is expensive to compute.

Use the REST API for environments without native Redis clients

Some platforms or languages do not support native Redis clients, or TLS SNI issues prevent connections. PandaStack exposes a REST API for the KV store (Upstash-style) as a fallback.

Replace the ioredis client with HTTP calls:

const fetch = require('node-fetch');

const KV_REST_URL = process.env.KV_REST_API_URL;
const KV_TOKEN = process.env.KV_REST_API_TOKEN;

async function setKey(key, value, ttl) {
  await fetch(`${KV_REST_URL}/setex/${key}/${ttl}`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KV_TOKEN}` },
    body: value
  });
}

async function getKey(key) {
  const res = await fetch(`${KV_REST_URL}/get/${key}`, {
    headers: { Authorization: `Bearer ${KV_TOKEN}` }
  });
  const data = await res.json();
  return data.result;
}

fastify.post('/login', async (request, reply) => {
  const { username } = request.body;
  const sessionId = `session:${Date.now()}:${Math.random()}`;

  await setKey(sessionId, JSON.stringify({ username, createdAt: Date.now() }), 3600);

  reply.send({ sessionId });
});

This works identically to the native client but uses HTTP instead of the Redis wire protocol. Latency is slightly higher (an extra network hop), but compatibility is universal.

The REST API supports most Redis commands: GET, SET, SETEX, INCR, DEL, EXPIRE, HGETALL, and more. Check the [Upstash REST API docs](https://upstash.com/docs/redis/features/restapi) for the full command reference (PandaStack's KV REST API follows the same contract).

Handle connection failures and retries

If the KV store is unavailable (maintenance, network issue), the API should degrade gracefully instead of crashing.

Wrap Redis calls in try-catch blocks:

fastify.get('/session/:id', async (request, reply) => {
  const { id } = request.params;

  try {
    const session = await redis.get(id);
    if (!session) {
      return reply.status(404).send({ error: 'Session not found or expired' });
    }
    reply.send(JSON.parse(session));
  } catch (err) {
    fastify.log.error('Redis error:', err);
    reply.status(503).send({ error: 'Session store unavailable' });
  }
});

This returns a 503 if Redis is down, signaling to the client that the service is temporarily unavailable. The app does not crash, and users can retry.

For critical flows (like login), fall back to a stateless mode (generate a signed JWT instead of a session ID) if Redis is unreachable. For non-critical flows (like caching), serve stale data or skip the cache and hit the database.

Monitor KV usage and memory limits

The KV store has a memory limit based on your plan. If you hit the limit, Redis evicts keys according to its eviction policy (typically LRU: least recently used).

Track memory usage in the dashboard under KV Store → Metrics. If usage approaches the limit, either upgrade to a larger plan or reduce TTLs to expire keys faster.

For session storage, shorter TTLs (1 hour instead of 24 hours) reduce memory usage at the cost of more frequent logins. For caching, tune TTLs based on how stale the data can be — a product catalog might tolerate 10-minute-old data, but a stock ticker needs sub-second freshness.

If a key is evicted before its TTL expires, reads return null. Your code should handle this by refetching the data (for caches) or returning 404 (for sessions).

Deploy from CI with environment variable injection

Script the deploy in CI and inject KV credentials:

curl -X POST https://api.pandastack.io/v1/projects \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "container",
    "name": "fastify-api",
    "repositoryName": "yourname/fastify-kv",
    "branch": "main",
    "autoDeploy": true
  }'

Link the KV store after creation:

curl -X POST https://api.pandastack.io/v1/projects/$PROJECT_ID/link-kv \
  -H "Authorization: Bearer $PANDASTACK_TOKEN" \
  -d '{"kvId": "$KV_ID"}'

The link injects KV_URL, KV_REST_API_URL, and KV_REST_API_TOKEN automatically on the next deploy.

Why managed KV beats self-hosted Redis

Self-hosted Redis requires persistence config (RDB snapshots or AOF logs), memory tuning (maxmemory-policy), and monitoring (memory usage, evictions, connection count). Managed KV handles all of this.

PandaStack's KV store runs on Kubernetes with automated failover. If the Redis pod crashes, Kubernetes restarts it and your app reconnects automatically (assuming your client has retry logic, which ioredis does by default).

Backups are not yet exposed in the dashboard for KV (as of mid-2026), so do not store critical data that cannot be regenerated. Use the KV store for ephemeral data (sessions, caches, rate-limit counters), not as a primary data store.

References

  • [Fastify documentation](https://www.fastify.io/docs/latest/)
  • [ioredis library](https://github.com/redis/ioredis)
  • [PandaStack KV store documentation](https://docs.pandastack.io)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also