Oolvay uses Redis as an optional caching layer through Upstash. It is not required. If you choose not to configure it, the application falls back to reading sessions directly from your Postgres database with no changes to behavior, no code to adjust, and no migrations to run.
Redis plays two roles in Oolvay.
| Role | What it does |
|---|---|
| Session cache | Stores active sessions in memory so authentication checks do not require a database query on every request |
| Health monitoring | Provides latency data for the infrastructure health panel in the admin dashboard |
In this guide, you will learn how sessions are stored and read, why the cookie cache is disabled and what that means for your Postgres load, how logout-everywhere and same-browser tab sync work with and without Redis, and how to decide whether adding Redis makes sense for your stage of product.
When Redis is not configured, Better Auth writes sessions to the session table in your Postgres database. When Redis is configured as secondaryStorage, Better Auth writes sessions to Redis instead. The session table still exists in your schema and is created by migrations, but it remains empty for the lifetime of that deployment. There is no duplication between the two stores. Whichever is active is the sole session store.
This also means there is no automatic fallback if Redis becomes unavailable mid-deployment. If Redis is configured and goes down, the following happens by operation.
| Operation | What happens if Redis is unavailable |
|---|---|
Session read (getServerSession) | Returns null, user is treated as unauthenticated and redirected to login |
| Session write (sign-in) | Fails silently, sign-in appears to succeed but no session is persisted, user is redirected to login on their next request |
| Session delete (sign-out) | Fails silently, session key may not be cleared, logged to Sentry as a warning |
All three failure paths are caught and reported to Sentry, if configured, and your application logger rather than thrown as unhandled exceptions, so failures are visible in your observability stack rather than surfacing as cryptic errors to users.
0 / 2,000 characters
Switching between stores requires only adding or removing the two Upstash environment variables and restarting the server. No migrations are needed and no application code changes.
getServerSession() is called on every authenticated server render, every server action, and every protected API route. However, multiple calls within the same server render pass are automatically deduplicated. getServerSession() is wrapped in React’s cache() function in lib/auth/get-server-session.ts, which memoizes the result for the duration of a single request. If your root layout, a nested layout, and a page component all call getServerSession(), only one actual request goes to the session store. The rest return the cached result from the first call. The memoization resets on every new request.
The practical consequence is that session store reads scale with the number of requests and SessionWatcher polling intervals, not with the number of components that call getServerSession() within a single render.
On top of that, SessionWatcher is a background component mounted at the root layout that polls getServerSession() every five minutes and again on every tab focus event. Across many concurrent users with multiple open tabs, this generates a continuous and predictable read load against your session store. Without Redis, every one of those polls is a live Postgres query. With Redis, most are served from the in-memory cache without touching Postgres at all.
All of these calls go directly to the session store on every request because Better Auth’s cookie-based session cache is disabled by default. How that default works and how to change it is covered under logOutEverywhereInstantly in the session configuration section below.
When a user revokes all sessions, Oolvay also broadcasts a LOGOUT message over a BroadcastChannel. Any other open tabs in the same browser receive this message and redirect to the login page immediately, without waiting for their next SessionWatcher poll. This behavior is instant and happens entirely in the browser, independent of Redis.
All session behavior is controlled by a single object in config/site.ts.
authAndSession: {
expiresInDays: 30,
updateAgeInDays: 1,
cookieMaxAgeInMinutes: 5,
logOutEverywhereInstantly: true,
enableSessionWatcher: true,
},How long a session remains valid from the time it was created. After this window, the user must sign in again regardless of activity.
| Value | Trade-off |
|---|---|
| Lower (7 to 14 days) | More frequent sign-ins, smaller window of exposure if a session token is compromised |
| Higher (30 to 90 days) | Better user experience for returning users, larger exposure window if a token is stolen |
30 days is a reasonable default for most SaaS products. Consumer applications with sensitive data often use shorter windows. Internal tools where users are trusted and sign in infrequently often use longer ones.
How frequently an active session’s expiry is extended. A value of 1 means that if a user is active, their session expiry is pushed forward once every 24 hours, keeping them signed in indefinitely as long as they keep using the product.
| Value | Trade-off |
|---|---|
| Lower (0 to 1 day) | Sessions stay fresh for active users, more frequent write operations to the session store |
| Higher (7+ days) | Fewer writes, but active users who happen to hit the expiry boundary get logged out unexpectedly |
Setting this to 1 paired with expiresInDays: 30 means a user who signs in once and visits every day will never be logged out. A user who goes inactive for 30 days will be asked to sign in again.
Controls whether Better Auth’s cookie cache is enabled.
When true, the cookie cache is disabled. Every call to getServerSession() goes directly to the session store. When a user revokes all sessions, every device is locked out on its next request with no delay.
When false, the cookie cache is enabled. The server trusts a signed cookie for up to cookieMaxAgeInMinutes without querying the session store. This reduces session store reads significantly but means that after a logout-everywhere event, other devices remain authenticated until their cookie expires. A warning is shown on the logout button to inform users of the delay.
| Setting | Session store reads | Logout propagation delay | Redis impact |
|---|---|---|---|
true | Every request | None, immediate | Every session check hits Redis or Postgres |
false | Once per cookie window | Up to cookieMaxAgeInMinutes | Far fewer reads, cookie absorbs most traffic |
If you are not configuring Redis and want to reduce Postgres session read load, setting this to false with a short cookieMaxAgeInMinutes is a reasonable trade-off. The default of true prioritizes security and correctness over read efficiency.
Only relevant when logOutEverywhereInstantly is false. Controls how long the signed session cookie is trusted before the server re-validates against the session store.
A value of 5 means at most a 5-minute window during which a revoked session could still be accepted on another device. Lower values close the window faster at the cost of more frequent session store reads. Higher values reduce reads but widen the revocation propagation window.
Controls whether SessionWatcher is mounted in the root layout.
When true, a background component polls getServerSession() every five minutes and on every tab focus event. This serves two purposes. First, it detects expired sessions automatically and redirects the user to login without them needing to navigate anywhere. Second, and more importantly, it is what makes logout-everywhere work across devices and browsers without a manual refresh. When a user revokes all sessions from one device, other devices discover the revocation within five minutes or on their next tab focus rather than remaining in a silently broken state indefinitely.
Same-browser tab sync is handled separately by a BroadcastChannel and is instant regardless of this setting. SessionWatcher covers the cross-device and cross-browser case that the broadcast channel cannot reach.
When false, revoked sessions on other devices are only detected when the user navigates to a protected route or triggers a server action. Users on long-lived pages such as dashboards or editors may remain on the page in a broken state until they interact with something that requires authentication.
The trade-off is cost. Every active tab generates one session store read every five minutes plus one on every tab focus. Across many concurrent users with multiple open tabs this becomes significant. Without Redis each poll is a live Postgres query. With Redis most are served from cache. If you are operating without Redis and want to reduce Postgres read load, disabling SessionWatcher is one lever, though it degrades the cross-device logout experience.
| Factor | Database table | Redis store |
|---|---|---|
| Setup | None | Upstash account and two environment variables |
| Cost | None | Free on Upstash’s hobby tier for most early-stage applications |
| Session read latency | 20 to 200ms | 1 to 10ms |
| Postgres load from auth checks | Every request | Most requests served from cache |
SessionWatcher poll cost | Live DB query every 5 minutes per active tab | Redis read, no Postgres involvement |
| Logout-everywhere correctness | Full, immediate | Full, immediate |
| Same-browser cross-tab logout | Instant via BroadcastChannel | Instant via BroadcastChannel |
| Operational complexity | Lower | One additional service to monitor |
| Failure behavior | Not applicable | No fallback. Redis errors are caught and logged to Sentry. Users are redirected to login until Redis recovers. |
| Source of truth | Postgres session table | Redis via secondaryStorage |
Both approaches use the same schema and the same auth logic. Switching between them requires only adding or removing two environment variables and restarting the server.