Skip to main content

User Session Caching with Sliding TTL

User sessions are the perfect use case for sliding TTL. A user browsing your site should keep their session alive with every request. A user who walks away should have their session expire automatically.

Fixed TTL does not work well here. If you set a 30-minute TTL, the session expires exactly 30 minutes after it was cached - even if the user is actively clicking around. With sliding TTL, each request extends the deadline by another 30 minutes.

The setup​

from inhouse import MemoryStore, inhouse_cache

session_store = MemoryStore(max_size=10000, default_ttl=1800, sliding=True)

@inhouse_cache(store=session_store)
def load_session(session_id: str) -> dict | None:
row = db.execute(
"SELECT user_id, role, permissions, expires_at FROM sessions WHERE id = ?",
(session_id,),
).fetchone()
return dict(row) if row else None

sliding=True on the store means every successful get() for this session extends its expiry by 1800 seconds (30 minutes). A user making requests every few seconds stays logged in indefinitely. A user who closes their browser and walks away has their session expire within 30 minutes.

Middleware that keeps sessions alive​

from fastapi import FastAPI, Request, Response
from inhouse import MemoryStore, inhouse_cache

store = MemoryStore(max_size=10000, default_ttl=1800, sliding=True)

@inhouse_cache(store=store, exclude=("request", "response"))
def get_session(request: Request, response: Response, session_id: str) -> dict | None:
...

Every request that includes a session ID calls get_session. The sliding TTL extends the session's life. No separate "heartbeat" endpoint needed.

Why sliding TTL beats fixed TTL​

With fixed TTL, a user who logs in at 10:00 has their session cached until 10:30. If they make a request at 10:29, the session is still valid - but it expires one minute later at 10:30, logging them out mid-browse.

# Fixed TTL - bad for sessions
@inhouse_cache(ttl_seconds=1800)
def load_session(session_id: str) -> dict | None:
... # expires exactly 1800s after cache write

# Sliding TTL - good for sessions
@inhouse_cache(ttl_seconds=1800, sliding=True)
def load_session(session_id: str) -> dict | None:
... # extends 1800s from each read

With sliding TTL, a request at 10:29 extends the deadline to 10:59. Another request at 10:45 pushes it to 11:15. The session stays alive as long as the user is active.

Session invalidation on logout​

When a user explicitly logs out, you need to evict their session immediately regardless of TTL.

def logout(session_id: str):
db.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
db.commit()

# Evict the cached session so the next request forces a fresh lookup
load_session.cache_invalidate(session_id=session_id)

The next request with that session ID misses the cache, finds no row in the database, and returns None - the user is logged out immediately.

Rate limiting companion​

Sliding sessions pair well with rate limit tracking. Cache a user's request count with sliding TTL. If they go idle, the counter resets naturally.

@inhouse_cache(ttl_seconds=60, sliding=True)
def get_rate_counter(ip_address: str) -> int:
return 0 # fresh counter on cache miss

def check_rate_limit(ip_address: str) -> bool:
count = get_rate_counter(ip_address)
if count > 100:
return False # rate limited
get_rate_counter.cache_invalidate(ip_address=ip_address)
get_rate_counter(ip_address) # re-caches incremented value
return True

Each request within the 60-second window extends the counter's life. If the user stops requesting, the counter expires naturally after 60 seconds of inactivity.

Caveats​

A continuously active user can keep a sliding entry alive indefinitely. The LRU eviction at max_size is the safety valve. If you have 10,000 concurrent active users and max_size=10000, the least recently active user gets evicted when a new one arrives. Set max_size high enough for your concurrent user base.

Sliding reuses the TTL duration stored at the last write. If you change store.default_ttl, existing entries keep their original duration until they miss and are re-cached.