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.