# Redis

> The @FrontMcp redis option: its fields, what FrontMCP keeps in Redis and under which keys, how long sessions last, what doesn't use it, and what happens when Redis can't be reached.

Source: https://frontmcp.dev/reference/deployment/redis

A FrontMCP server keeps some state between requests: the sessions of clients on protocol versions before 2026-07-28, background tasks, and questions waiting for the user's answer. By default that state lives in the process, so it's lost on a restart and invisible to a second instance. `@FrontMcp({ redis })` moves it to Redis, where it survives restarts and every instance sees it. Clients on MCP 2026-07-28 keep no session, so a server that only serves them needs Redis for less than you might expect.

```ts
@FrontMcp({ redis: { url, keyPrefix?, defaultTtlMs? } })
@FrontMcp({ redis: { host, port?, password?, db?, tls?, keyPrefix?, defaultTtlMs? } })
```

---

## Reference

### `redis`

```ts main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDesk } from "./help-desk.app";

@FrontMcp({
  info: { name: "help-desk", version: "1.0.0" },
  apps: [HelpDesk],
  redis: { url: process.env.REDIS_URL ?? "redis://127.0.0.1:6379" },
})
export default class Server {}
```

[See more examples below.](#usage)

#### Options

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `url` | `string` | | A `redis://` or `rediss://` URL, `redis://[:password@]host[:port][/db]`, in place of the next five fields: `rediss://` sets `tls`. The user, when there is one, must be `default`. Since 1.9. |
| `host` | `string` | | The Redis server. Required without `url`. |
| `port` | `number` | `6379` | |
| `password` | `string` | none | |
| `db` | `number` | `0` | The database number. |
| `tls` | `boolean` | `false` | Connect over TLS, as managed Redis services usually require. |
| `keyPrefix` | `string` | `"mcp:"` | Put before the session keys, and the keys of pending questions. Background tasks ignore it. |
| `defaultTtlMs` | `number` | `3600000` | How long a session's key lives, in milliseconds. See [how long sessions last](#how-long-sessions-last). |
| `provider` | `"redis"` or `"vercel-kv"` | `"redis"` | `"vercel-kv"` talks to Vercel KV or Upstash over HTTP instead, with `url` and `token`: see [Vercel](https://frontmcp.dev/reference/deployment/vercel#storage). |

Give either `url` or the fields. With a `url`, the URL is the base, and `port`, `password`, `db` and `tls` beside it fill in only what it leaves out: `{ url: "redis://127.0.0.1", port: 6392 }` connected on port 6392, and `{ url: "redis://127.0.0.1:6391", password }` signed in with the password. A field that contradicts the URL stops the server at startup, with a `ZodError` whose message names the field and not its value:

```text
redis host contradicts redis.url. Fields beside a url only fill in what the URL leaves out (port, password, db, tls); put the value in the URL or drop the field.
```

So `host` beside a `url` always fails, and `port` or `db` fails when the URL has its own (`port contradicts`, `db contradicts`); a field equal to the URL's value is accepted. Before 1.9.2, `host` won and the URL was ignored, and a `password` beside a `url` was dropped without a word. `keyPrefix` and `defaultTtlMs` go next to either. A `url` FrontMCP can't use stops the server at startup, with `redis.url names the ACL user "support"; only the default user is supported`, `redis.url must use the redis:// or rediss:// scheme, got "http://"` or `redis.url is not a valid URL (expected redis://[[user]:password@]host[:port][/db])`.

[`pubsub`](https://frontmcp.dev/reference/sdk/frontmcp#options), a second option of the same shape, `url` included, is the Redis for resource-subscription events. It defaults to `redis`, and is needed when `redis` is Vercel KV, which has no publish and subscribe.

`throttle.storage`'s `redis.config` ([below](#when-redis-cant-be-reached)) and, since 1.9.3, [`jobs.store.redis`](https://frontmcp.dev/reference/sdk/job#frontmcp-jobs-) read a `url` and the fields beside it by the same rule: `jobs: { enabled: true, store: { redis: { url: "redis://127.0.0.1:6399", port: 6400 } } }` stopped the server at startup with `redis port contradicts redis.url`, and `{ url: "redis://127.0.0.1", port: 6398 }` dialled port 6398 (`connect ECONNREFUSED 127.0.0.1:6398`, with nothing listening there). (In 1.9.2 the job stores used only `host` and `port`.)

### What's kept in Redis

| State | With `redis` set | Key |
| --- | --- | --- |
| Sessions of clients before MCP 2026-07-28 | In Redis, unless `transport.persistence` is `false`. The server logs `redis session store will be initialized for transport persistence`. | `mcp:session:<session id>`, a JSON string with the session, the hash of its authorization, `createdAt`, `lastAccessedAt`, `initialized` and the client's capabilities. It isn't encrypted. |
| Background tasks | In Redis: `Created task store { type: 'redis', keyPrefix: 'mcp:task:' }`. | Under `mcp:task:`, whatever `keyPrefix` says. |
| Questions waiting for an answer (`elicitation: { enabled: true }`) | In `elicitation.redis`, else in `redis`. The startup log says `Created elicitation store { type: 'redis', … }`. | `mcp:pending:<session id>`, under `keyPrefix`, while a question of an older client waits. Clients on MCP 2026-07-28 carry their answers in `requestState`, and nothing is stored. |
| SSE events, for clients that resume a stream | Only with `transport.eventStore`, or in [distributed mode](https://frontmcp.dev/reference/deployment/high-availability). | |
| Plugin stores | Only for plugins initialized with `type: "global-store"`, like `CachePlugin.init({ type: "global-store" })`, which use the server's `redis`. See [Cache](https://frontmcp.dev/reference/plugins/cache#stores) and [Remember](https://frontmcp.dev/reference/plugins/remember#stores). | The plugin's own |

Clients on MCP 2026-07-28 open no session: nothing is written to Redis for their calls unless a tool starts a task or asks a question.

These have storage options of their own, and don't use `redis`:

| State | Option | Page |
| --- | --- | --- |
| `local` and `remote` auth: pending sign-ins, codes, refresh tokens | `auth.tokenStorage: { redis }` | [Local auth](https://frontmcp.dev/reference/auth/local#storage) |
| Job and workflow runs | `jobs.store: { redis }`, with the same fields as `redis`, `url` included | [Job](https://frontmcp.dev/reference/sdk/job#frontmcp-jobs-) |
| Rate-limit and concurrency counts | `throttle.storage`: keys `mcp:guard:<entity>:…`. In production a server refuses to start when it can't reach that Redis, unless `fallback: "memory"`: see [below](#when-redis-cant-be-reached). | [Guard options](https://frontmcp.dev/reference/sdk/guard#storage-and-keyprefix) |

#### From the environment

Without `redis`, the environment variables `REDIS_URL` or `REDIS_HOST` move **background tasks**, and questions waiting for an answer when `elicitation` is on, to Redis. Sessions stay in memory, and so do rate-limit counts. So `REDIS_HOST=redis`, which the Docker Compose file of `frontmcp create` sets, doesn't store sessions until `main.ts` reads it into `redis`. `KV_REST_API_URL` is checked too: the server [turns background tasks off](https://frontmcp.dev/reference/deployment/vercel#storage) with a warning, since Vercel KV can't serve them, and refuses `elicitation: { enabled: true }` unless `elicitation.redis` names a real Redis.

### How long sessions last

A session's key lives an hour after it's written, or `redis.defaultTtlMs`, in milliseconds, when you set it. FrontMCP writes the key when the session starts, and again when an instance loads a session it doesn't have in memory. The instance that holds the session pushes the expiry out again while it serves the session's requests, at most once every quarter of the TTL, so a session used all day on one instance stays in Redis, and another instance can still pick it up if that one stops.

```ts
redis: { host: "127.0.0.1", port: 6379, defaultTtlMs: 600_000 },   // sessions live 600 s
transport: { persistence: { defaultTtlMs: 600_000 } },             // the same, and it wins if both are set
```

With `defaultTtlMs: 60000`, `redis-cli ttl` showed `60` after `initialize`, `40` twenty seconds later, and `60` again after a request on the instance that holds the session. (With 1.8.7, a request that reached the other instance set it back too.) Changed in 1.8.7: in 1.8.6 `redis.defaultTtlMs` was ignored, and a session its own instance kept serving expired an hour after it was loaded.

Once the key has expired, the session is gone on every instance, the one that holds it in memory included: with `defaultTtlMs: 4000`, a request after six idle seconds got `404` `{"code":-32001,"message":"session expired"}`. Changed in 1.9.3: an instance now checks that the key is still there before it serves a session from memory, one `EXISTS` per request; in 1.9.2 the instance that held the session kept serving it. Since 1.9.4 it waits at most `transport.persistence.sessionCheckTimeoutMs` for the answer ([below](#when-redis-cant-be-reached)).

### When Redis can't be reached

| When | What happens |
| --- | --- |
| At startup | The server starts anyway, keeps sessions in memory, and logs `[RedisSessionStore] Connection failed`, `[TransportService] Failed to connect to redis - session persistence disabled` and `[storage] Warning: Failed to connect to redis, falling back to memory.` FrontMCP then retries with a growing delay (about 1, 2, 4 and 8 seconds apart in a run, and on). When Redis answers, it logs `Session store connection validated successfully` and `Session store recovered after startup failure`, and sessions go to Redis from then on. `/readyz` answers `503`, with the probe `session-store` `unhealthy` and `"error": "Session store ping returned false"`, until then. |
| While running | Clients keep working, with sessions in the instance's memory. `/readyz` answers `503` until Redis is back, then `200` again. A Redis that stops answering without closing its connections, like a paused container, is waited for at most `transport.persistence.sessionCheckTimeoutMs` (500 ms by default) on a session the instance holds, which it then serves from memory; a request that has to load its session from Redis waits until Redis answers ([High availability](https://frontmcp.dev/reference/deployment/high-availability#caveats)). The log has `[RedisStorage] Redis connection error: connect ECONNREFUSED …` once, then the same line about every 30 seconds with `(N similar error(s) suppressed)`. |
| `throttle.storage`, at startup | In production, the server doesn't start: `GuardStorageUnavailableError: throttle.storage (redis) is unavailable: Failed to connect to Redis: connect ECONNREFUSED …`. Rate limits fail closed, so add `fallback: "memory"` to `throttle.storage` to start with per-instance counters instead. Outside production, `memory` is the default. |
| `throttle.storage`, while running | A call to a tool that has a rate limit or a concurrency limit fails, until Redis is back, with the tool error `GUARD_STORAGE_UNAVAILABLE` (HTTP `200`, `isError`) and the text `Service temporarily unavailable: the rate-limit store cannot be reached. Retry shortly.`; other tools aren't affected. The log has the cause. With `throttle.global`, which is checked for every request, every request answers `503` with `Retry-After: 1` and `{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"Service temporarily unavailable: the rate-limit store cannot be reached","data":{"code":"GUARD_STORAGE_UNAVAILABLE"}}}`. With `fallback: "memory"` the calls are counted in the instance's memory instead; outside production that's the default, here too. It logs once, `GuardManager: throttle.storage (redis) is unavailable: … Using per-instance in-memory counters until it answers again`, and the first limit check 30 seconds or more after the last failure tries Redis again: when it answers, the log says `GuardManager: throttle.storage (redis) is available again`. |

`transport.persistence.sessionCheckTimeoutMs`, a positive whole number of milliseconds, sets how long an instance waits for Redis before it serves a session it holds from memory:

```ts
redis: { url: process.env.REDIS_URL! },
transport: { persistence: { sessionCheckTimeoutMs: 2000 } },   // then serve from memory, and check again next time
```

With the Redis container paused, a call on such a session answered `200` after two seconds, and the log said `[TransportService] Could not confirm the session is still stored — serving it here` with `The session store did not answer within 2000 ms`. A value that isn't a positive whole number stops the server at startup with a `ZodError` naming `sessionCheckTimeoutMs`: `Too small: expected number to be >0`, or `expected int, received number`. New in 1.9.4: in 1.9.3 the check waited for Redis with no limit.

A missing password gives `NOAUTH Authentication required.` in place of the connection error, and a wrong one `WRONGPASS invalid username-password pair or user is disabled.` So Redis failing doesn't take the server down, but it stops sessions from being shared: point your orchestrator's readiness check at [`/readyz`](https://frontmcp.dev/reference/deployment/health-and-metrics) so an instance without Redis gets no traffic.

`auth.tokenStorage` is the exception: when its Redis can't be reached, the server doesn't start. See [Local auth](https://frontmcp.dev/reference/auth/local#storage). The guard's side of an outage is on [the guard reference](https://frontmcp.dev/reference/sdk/guard#calls-fail-with-guard_storage_unavailable-while-the-server-runs).

```ts
throttle: { enabled: true, storage: { type: "redis", redis: { config: { host: "127.0.0.1" } }, fallback: "memory" } },
```

`throttle.storage`'s `redis.config` takes a `url` too since 1.9.2: `config: { url: "redis://127.0.0.1:6392" }` started in production and connected.

With `fallback: "memory"`, two instances count a limit of two a window separately while Redis is down: with one call each, then another to each, all four went through. When Redis is back they share one count again, in the same key.

> **Note**
In 1.8.7 the tool error's text named the store and its address, and with `throttle.global` the outage reached the client as a bare `500`.

> **Note**
Before, a Redis that was down at startup disabled the session store until the instance restarted, a Redis that went away made ioredis log `[ioredis] Unhandled error event: Error: connect ECONNREFUSED …` over and over, and a limited tool failed with an internal error while it was gone. Now the store retries, the log is one rate-limited line, and a limited tool fails with a code you can read. Changed in 1.8.6: before, the startup failure of `throttle.storage` was ioredis's own `StorageConnectionError: Failed to connect to Redis`, and rate-limit keys were `mcp:guard::<entity>:…`, with an empty segment.

#### Caveats

- Sessions are stored as plain JSON. Keep Redis on a private network, with a password, and TLS where the network isn't yours.
- With `FRONTMCP_DEPLOYMENT_MODE=distributed`, a session stays with the instance that made it, and the others relay its requests there through Redis. See [High availability](https://frontmcp.dev/reference/deployment/high-availability#distributed-mode).
- Redis can't be used from a Cloudflare Worker. The Cloudflare build refuses a `redis` block in the entry, and one spread in only when a variable is set gets past a build without that variable, then answers every request `503` `SERVER_START_FAILED` when the Worker has it: see [Cloudflare Workers](https://frontmcp.dev/reference/deployment/cloudflare-workers#storage).
- The Playground checks how `redis` and its `url` are read ([Running Several Instances](https://frontmcp.dev/learn/running-several-instances#what-else-lives-in-one-instances-memory)), but has no Redis to connect to. The rest was run against Valkey, which speaks Redis's protocol, in Docker, and the keys shown are what `redis-cli --scan` listed. With FrontMCP 1.9.4 and Valkey 8: sessions across two instances on one machine, and a Redis that stops answering (a paused container). With 1.9.3: `jobs.store.redis` with a `url`. With 1.9.2 and Valkey 8: `url` with fields beside it, a missing password (`NOAUTH`) and `throttle.storage` with a `url`. With 1.9.1 and Valkey 9: the keys and `keyPrefix`, sessions behind nginx, how long they last and the TTL refresh, a Redis that's down at startup and comes back, rate limits with `throttle.storage`, its Redis down at startup, and going away while the server runs, with and without `throttle.global`. The fallback while running and a session store's Redis going away while running were last run with 1.8.7, a wrong password with 1.8.6, and TLS wasn't tried.

---

## Usage

### Storing sessions in Redis

Run a Redis, and give the server its address:

```bash
docker run -d --name desk-redis -p 127.0.0.1:6379:6379 redis:7-alpine
export MCP_SESSION_SECRET="$(openssl rand -hex 32)"
REDIS_URL=redis://127.0.0.1:6379 node dist/node/help-desk.bundle.js
```

With the configuration [at the top of the page](#redis), the server logs:

```text
[TransportService] redis session store will be initialized for transport persistence
[TaskStoreFactory] Created task store { type: 'redis', keyPrefix: 'mcp:task:', supportsPubSub: true }
[TransportService] Session store connection validated successfully
```

A client on MCP 2025-06-18 that sends `initialize` gets an `Mcp-Session-Id`, and the session appears in Redis:

```bash
docker exec desk-redis redis-cli --scan
```

```text
mcp:session:<session id>
```

Restart the server, and the client's next `tools/call` with the same `Mcp-Session-Id` works: the server logs `Recreating transport from stored session`. Without `redis`, the same request after a restart gets `404` `{"jsonrpc":"2.0","error":{"code":-32000,"message":"session not initialized"}}`: the id is still one the server can read, and there's no session behind it.

The secret is what makes the id readable after a restart. Outside production, without `MCP_SESSION_SECRET`, FrontMCP encrypts ids with a key made from the instance's machine id, which is new on every start, so the old id gets `404` `invalid session id` even with `redis`. Setting `MACHINE_ID` fixes that in development; production needs the secret anyway.

### Sharing one Redis between servers

Give each server its own `keyPrefix`, so their sessions don't mix:

```ts
redis: { url: process.env.REDIS_URL!, keyPrefix: "desk:" },
```

Sessions are then `desk:session:<id>`. Background tasks still use `mcp:task:` on every server; use a separate `db` if two servers must not see each other's tasks.

### Connecting to managed Redis

Managed services give a URL with the password in it, often `rediss://` for TLS. Pass it as it is, from the environment:

```ts
redis: { url: process.env.REDIS_URL! }, // rediss://default:<password>@cache.example.com:6380
```

FrontMCP reads it as `host: "cache.example.com"`, `port: 6380`, the password, and `tls: true`. A service that gives the parts separately takes them as fields: `{ host, port, password, tls: true }`. TLS wasn't tried for this page.

Changed in 1.9: in 1.8.7 `redis` had no `url`, and a managed Redis's URL had to be split into fields.

### Making readiness depend on Redis

Nothing to configure: with `redis` set, `/readyz` includes the `session-store` probe, which pings Redis:

```json
{"status":"ready","totalLatencyMs":3,"catalog":{"toolsHash":"<hash of the tool names>","toolCount":1,"resourceCount":0,"promptCount":0,"skillCount":0,"agentCount":0},"probes":{"session-store":{"status":"healthy","latencyMs":1}}}
```

In production, `probes` is left out of the answer, and only the status tells you. See [Health checks and metrics](https://frontmcp.dev/reference/deployment/health-and-metrics).

---

## Troubleshooting

### `[RedisStorage] Redis connection error: connect ECONNREFUSED 127.0.0.1:6379`

A server that had Redis lost it, or Redis isn't where `host` and `port` point. The line appears once, then about every 30 seconds with a count of the errors it left out, while FrontMCP reconnects; `/readyz` answers `503`, and clients keep working with sessions in memory. When Redis is back, the check passes again. Inside Docker Compose, the host is the service's name, like `redis`, not `localhost`. (In 1.8.6 this was `[ioredis] Unhandled error event: …`, over and over.)

### `Could not confirm the session is still stored — serving it here`

A warning, from an instance that holds a session in memory and couldn't check that Redis still has it: Redis refused the connection, or didn't answer within `transport.persistence.sessionCheckTimeoutMs`. The `error` beside it says which: the Redis client's own error, or `The session store did not answer within 500 ms`. The request was served from memory, and the next one checks again. Look at Redis; raise `sessionCheckTimeoutMs` only if Redis is slow rather than gone. See [when Redis can't be reached](#when-redis-cant-be-reached).

### `Failed to connect to redis - session persistence disabled`

The first connection failed, so this instance keeps sessions in memory until FrontMCP's next try works: it retries with a growing delay and logs `Session store recovered after startup failure` when it does, with no restart. If it never recovers, `NOAUTH Authentication required.` in the same message means the password is missing, and `WRONGPASS invalid username-password pair or user is disabled.` that it's wrong.

### `GuardStorageUnavailableError: throttle.storage (redis) is unavailable`

The server has `throttle.storage` on Redis, runs in production, and couldn't reach that Redis as it started. It exits, because rate limits fail closed. Fix the connection, or add `fallback: "memory"` to `throttle.storage` to start with per-instance counters. See [when Redis can't be reached](#when-redis-cant-be-reached).

### A tool fails with `GUARD_STORAGE_UNAVAILABLE`

The tool has a rate limit or a concurrency limit, and `throttle.storage`'s Redis stopped answering while the server runs. The call is refused, because a limit that can't be counted isn't applied. It works again when Redis does. Add `fallback: "memory"` to keep serving with per-instance counters meanwhile.

### `404` `invalid session id` from an instance with Redis

The session id wasn't made under this instance's `MCP_SESSION_SECRET`: the instances have different secrets, or the secret was rotated. The key is still in Redis until it expires, and the instance ignores it. The client starts a new session. See [High availability](https://frontmcp.dev/reference/deployment/high-availability#caveats).

### `ZodError` `invalid_union` for `redis`

`redis` got a shape it doesn't accept, like one with neither `url` nor `host`, or a `url` FrontMCP 1.8.7 and earlier don't read. The process exits with `Invalid input: expected "redis"`, `expected "vercel-kv"` and `expected string, received undefined`. Give `redis` a `url` or a `host`. The messages of a `url` it can't use are [above](#options).

### `NOAUTH Authentication required.` with `redis: { url }`

Redis wants a password, and neither the URL nor a `password` beside it gives one. Add it either way: `redis://:<password>@host:6379`, or `{ url, password }`. Before 1.9.2 a `password` beside `url` was ignored, so only the URL worked.

### `redis host contradicts redis.url`

A field beside `url` says something the URL already says differently: `host` always, `port` or `db` when the URL has them too. Put the value in the URL, or drop the field. See [options](#options).

### Older clients get `404` `session not initialized` after a restart or a deploy

Their sessions were in memory. Set `redis`, and check the startup log for `redis session store will be initialized`. If the answer is `404` `invalid session id` instead, the id was made under another `MCP_SESSION_SECRET`: see [above](#storing-sessions-in-redis).

### Sessions expire after an hour, though `redis.defaultTtlMs` is set

Before 1.8.7, `redis.defaultTtlMs` didn't apply to sessions; `transport.persistence.defaultTtlMs` did, and still wins when both are set. See [how long sessions last](#how-long-sessions-last).

### `/readyz` answers `503` with `Session store ping returned false`

The server can't reach Redis. It keeps serving; once Redis is back the check passes again, with no restart.

### `REDIS_URL` or `REDIS_HOST` is set but sessions aren't in Redis

The environment variable alone only moves background tasks and pending questions. Read it into `@FrontMcp({ redis })`. See [from the environment](#from-the-environment).
