Summary
The webapp's env schema in apps/webapp/app/env.server.ts declares per-subsystem Redis reader env vars (CACHE_REDIS_READER_HOST, RATE_LIMIT_REDIS_READER_HOST, REALTIME_STREAMS_REDIS_READER_HOST, PUBSUB_REDIS_READER_HOST, ALERT_RATE_LIMITER_REDIS_READER_HOST, TASK_META_CACHE_REDIS_READER_HOST), each of which falls back to the global REDIS_READER_HOST / REDIS_READER_PORT. The self-hosting docs at docs/self-hosting/env/webapp.mdx document REDIS_READER_HOST as the way to use a Redis read replica.
However, no subsystem actually reads env.*_REDIS_READER_HOST — every consumer instantiates a single ioredis client off the primary *_REDIS_HOST/*_REDIS_PORT. Setting REDIS_READER_HOST on a self-hosted webapp is a complete no-op today.
Evidence
Repo-wide grep for any consumer of the reader-host names (excluding the schema declaration file):
$ grep -rn 'CACHE_REDIS_READER_HOST\|RATE_LIMIT_REDIS_READER_HOST\|REALTIME_STREAMS_REDIS_READER_HOST\|PUBSUB_REDIS_READER_HOST\|ALERT_RATE_LIMITER_REDIS_READER_HOST\|TASK_META_CACHE_REDIS_READER_HOST\|readerHost\|reader_host' apps/webapp internal-packages
apps/webapp/app/env.server.ts: … # declarations only
Only one file matches — env.server.ts itself.
Example consumer (apps/webapp/app/services/taskIdentifierCache.server.ts:42-60):
function initializeRedis(): Redis | undefined {
const host = env.CACHE_REDIS_HOST;
if (!host) return undefined;
return new Redis({
connectionName: "taskIdentifierCache",
host,
port: env.CACHE_REDIS_PORT,
username: env.CACHE_REDIS_USERNAME,
password: env.CACHE_REDIS_PASSWORD,
// … no CACHE_REDIS_READER_HOST anywhere
});
}
The same single-client pattern is used in every other *_REDIS_HOST consumer: apiRateLimit.server.ts, realtimeClientGlobal.server.ts, services/realtime/v1StreamsGlobal.server.ts, tracePubSub.server.ts, projectPubSub.server.ts, magicLinkRateLimiter.server.ts, etc.
Compare with DATABASE_READ_REPLICA_URL, which is properly wired in apps/webapp/app/db.server.ts:249 — it constructs a second PrismaClient exposed as $replica. Prisma read splits work as documented; Redis ones don't.
Impact
Self-hosters who provision an ElastiCache reader endpoint (or any Redis replica) and set REDIS_READER_HOST per the docs see no traffic on the replica. We confirmed this on our production deploy: after binding REDIS_READER_HOST for ~24 h on 8 webapp pods, the replica node held a flat ~6 connections (cluster-internal replication only) and engine CPU was unchanged vs the primary. The Aurora half of the same change — DATABASE_READ_REPLICA_URL — worked as advertised and absorbed ~44 sustained connections plus ~3% CPU.
Suggested fix
Two reasonable shapes:
-
Per-subsystem twin client. Wherever env.<NAME>_REDIS_HOST is consumed today, also accept env.<NAME>_REDIS_READER_HOST/PORT, and when present, construct a second ioredis client. Provide a small splitRedis({ writer, reader }) helper that routes read commands (GET/HGET/SMEMBERS/HGETALL/ZRANGE/EXISTS/etc.) to the reader and writes to the writer.
-
Drop the orphan declarations. If a Redis read split isn't on the roadmap, remove the *_REDIS_READER_HOST/*_REDIS_READER_PORT entries from env.server.ts and the REDIS_READER_HOST row from docs/self-hosting/env/webapp.mdx so users don't waste deploy cycles wiring them up.
Happy to put up a PR for (1) if you'd like — would be useful to know if there's an intended design (e.g. is the eventual goal cluster-mode + read-from-replicas, or twin-client?).
Versions
Reproduced against main (current HEAD); also present in the v4.4.x release we run in prod.
Summary
The webapp's env schema in
apps/webapp/app/env.server.tsdeclares per-subsystem Redis reader env vars (CACHE_REDIS_READER_HOST,RATE_LIMIT_REDIS_READER_HOST,REALTIME_STREAMS_REDIS_READER_HOST,PUBSUB_REDIS_READER_HOST,ALERT_RATE_LIMITER_REDIS_READER_HOST,TASK_META_CACHE_REDIS_READER_HOST), each of which falls back to the globalREDIS_READER_HOST/REDIS_READER_PORT. The self-hosting docs atdocs/self-hosting/env/webapp.mdxdocumentREDIS_READER_HOSTas the way to use a Redis read replica.However, no subsystem actually reads
env.*_REDIS_READER_HOST— every consumer instantiates a single ioredis client off the primary*_REDIS_HOST/*_REDIS_PORT. SettingREDIS_READER_HOSTon a self-hosted webapp is a complete no-op today.Evidence
Repo-wide grep for any consumer of the reader-host names (excluding the schema declaration file):
Only one file matches —
env.server.tsitself.Example consumer (
apps/webapp/app/services/taskIdentifierCache.server.ts:42-60):The same single-client pattern is used in every other
*_REDIS_HOSTconsumer:apiRateLimit.server.ts,realtimeClientGlobal.server.ts,services/realtime/v1StreamsGlobal.server.ts,tracePubSub.server.ts,projectPubSub.server.ts,magicLinkRateLimiter.server.ts, etc.Compare with
DATABASE_READ_REPLICA_URL, which is properly wired inapps/webapp/app/db.server.ts:249— it constructs a secondPrismaClientexposed as$replica. Prisma read splits work as documented; Redis ones don't.Impact
Self-hosters who provision an ElastiCache reader endpoint (or any Redis replica) and set
REDIS_READER_HOSTper the docs see no traffic on the replica. We confirmed this on our production deploy: after bindingREDIS_READER_HOSTfor ~24 h on 8 webapp pods, the replica node held a flat ~6 connections (cluster-internal replication only) and engine CPU was unchanged vs the primary. The Aurora half of the same change —DATABASE_READ_REPLICA_URL— worked as advertised and absorbed ~44 sustained connections plus ~3% CPU.Suggested fix
Two reasonable shapes:
Per-subsystem twin client. Wherever
env.<NAME>_REDIS_HOSTis consumed today, also acceptenv.<NAME>_REDIS_READER_HOST/PORT, and when present, construct a second ioredis client. Provide a smallsplitRedis({ writer, reader })helper that routes read commands (GET/HGET/SMEMBERS/HGETALL/ZRANGE/EXISTS/etc.) to the reader and writes to the writer.Drop the orphan declarations. If a Redis read split isn't on the roadmap, remove the
*_REDIS_READER_HOST/*_REDIS_READER_PORTentries fromenv.server.tsand theREDIS_READER_HOSTrow fromdocs/self-hosting/env/webapp.mdxso users don't waste deploy cycles wiring them up.Happy to put up a PR for (1) if you'd like — would be useful to know if there's an intended design (e.g. is the eventual goal cluster-mode + read-from-replicas, or twin-client?).
Versions
Reproduced against
main(current HEAD); also present in the v4.4.x release we run in prod.