mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-11 10:34:13 -05:00
[GH-ISSUE #23793] feat: cache get_user_by_id — 25% of all DB queries with zero caching #107069
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Originally created by @ashm-dev on GitHub (Apr 16, 2026).
Original GitHub issue: https://github.com/open-webui/open-webui/issues/23793
Check Existing Issues
Verify Feature Scope
Problem Description
Users.get_user_by_id()generates a fullSELECTof all 21 columns from theusertable on every authenticated request. There is no caching at any level. This single query accounts for ~25% of all database queries by count and is the heaviest by cumulative time.Why it's so frequent:
Auth dependency (
utils/auth.py:356):get_current_user()callsget_user_by_id()on every request. 372 endpoints across 28 router files depend on it viaDepends(get_current_user|get_verified_user|get_admin_user).Double-fetch in handlers: ~15 endpoints receive
userfrom auth dependency, then callget_user_by_id(user.id)again (e.g.routers/users.py:279,342,369,387,406).WebSocket events (
socket/main.py:354,382,429,451): Each event re-parses JWT and re-fetches user from DB instead of using the already-populatedSESSION_POOL.N+1 in message/channel listing (
models/messages.py:220,routers/channels.py:931,1190,1274): Fetches user per message individually — 50 messages = 50 user SELECTs.Tools/Functions (
models/tools.py:238,256,models/functions.py:351,369,tools/builtin.py:476,2534,2610,2685,2745): Each load/execution re-fetches user.Current state:
DATABASE_URLREDIS_URLenv var, available asapp.state.redis(can beNone)Desired Solution you'd like
Tier 1 — no new dependencies, works on any DB/deploy config:
Request-scoped cache: Store user on
request.stateafter first fetch inget_current_user(). Eliminates all double-fetches within the same HTTP request. Zero cost.In-process TTL cache for
get_user_by_id(): Simple dict with expiration (30-60s), keyed by user ID. Invalidate onupdate_user_*/delete_user_*within the same process. Per-worker — each worker maintains its own cache. Stale window = TTL duration, acceptable sinceid/role/emailrarely change.Column projection for auth path:
get_current_user()only needsid,role,email,name. Use SQLAlchemyload_only()— skip JSON blobs (settings,oauth,scim,info). Full model loaded only when needed. DB-agnostic.Batch user loading for message lists: Replace N individual
get_user_by_id()calls with oneWHERE user.id IN (...)for channels/threads. SQLAlchemyin_()works on all backends.Use
SESSION_POOLin socket handlers:user-join,join-channels,join-note— user data already inSESSION_POOL[sid]afterconnect. No DB round-trip needed.Tier 2 — when Redis is available (
REDIS_URLis set):app.state.redis is not None, use Redis as shared user cache across workers. Same TTL. Invalidate on user mutation. Falls back to Tier 1 in-process cache when Redis unavailable. Infrastructure already exists (utils/redis.py,get_redis_client(),REDIS_KEY_PREFIX).Expected impact
Alternatives Considered
is_valid_token) depends on checking Redis per-request anyway, so JWT-only auth doesn't fully eliminate the round-trip.get_async_db_contextcreates a new context per call by design — see comment atutils/auth.py:302-305).Additional Context
@Classic298 commented on GitHub (Apr 16, 2026):
MySQL is not a supported database backend. Hallucination
@ashm-dev commented on GitHub (Apr 16, 2026):
Pardon, I deleted it
@Classic298 commented on GitHub (Apr 16, 2026):
We won't be caching
get_user_by_idat the database layer. A few reasons:Consistency trumps performance for this row. The user row is the source of truth for
roleand is consulted on every authenticated request. A 30–60s TTL means role changes (user -> pending, pending -> user), bans (pending, user deletion), and other permission mutations would take up to a minute to propagate with 60s TTL. That's a security regression. Demoting an admin or revoking access needs to be effective immediately, not 1 minute later. The same applies to token revocation logic, which must not be short-circuited by a stale user cache.Multi-worker invalidation is a real bug surface, not a handwave. With uvicorn running N workers, an in-process TTL cache means a mutation in worker A does not invalidate workers B..N. You end up with per-worker stale windows that are effectively non-deterministic from the client's perspective ("sometimes my permission change works, sometimes it doesn't for 60s"). Redis-backed caching fixes that but then you're paying a network round-trip to a cache to avoid a network round-trip to the DB. And we already pay a Redis round-trip per request for token revocation (
is_valid_token), so the saving is marginal at best.In this case you'd trade the database query for redis queries and cross-worker communication overhead.
The query itself is cheap. It's a single-row SELECT by primary key on an indexed column, sub-millisecond on SQLite and Postgres. There is no join, no scan, no aggregation. The database is doing exactly one B-tree lookup. No cache we add in front of this will meaningfully improve the performance or beat that for the read path, and any cache adds invalidation complexity, stale-data risk, and a new class of bugs and maintenance burden and debugging complexity.
What does look interesting, because those are just "stop doing unnecessary work":
userfromDepends(get_verified_user)and then immediately refetches the same row (e.g.routers/users.py:279,342,369,387,406). That's a delete, not a cache.routers/channels.py:931by using the existingget_users_by_user_idsbatch helper.SESSION_POOL[sid]in the socket handlers that already have the user data in memory post-connect.settings/oauth/scim/infoJSON blobs when all we need isid/role/email/name).The TTL/Redis caching proposals would be closed as won't-fix. The consistency cost isn't worth the (practically non-existent) savings on an already cheap query plus security regressions.
@Classic298 commented on GitHub (Apr 16, 2026):
25% of all queries (source?) doesn't matter
Frequency != Performance
it's one of the cheapest queries on the backend. You'd be optimizing the wrong thing
@ashm-dev commented on GitHub (Apr 16, 2026):
We have Sentry attached to our Open WebUI instance. When filtering traces by db.query spans, this specific query accounts for ~25% of total span duration across all database queries. That's not a guess — it's observed production telemetry.
Frequency * cost-per-call = total cost. The query is cheap individually, yes. But when it fires on every authenticated request, every socket event, and N times per channel message list — the cumulative time adds up. That's exactly what the Sentry data shows.
@Classic298 commented on GitHub (Apr 16, 2026):
@ashm-dev are you experiencing slowness / performance issues?
if yes - what is your setup? In the past every single time someone reported slow database queries it boiled down to the network round trip time between their open webui and the database due to cloud deployment of the database and multiple miliseconds (sometimes hundreds of milliseconds) of latency
@Classic298 commented on GitHub (Apr 16, 2026):
As I said above, fixing the call sites by removing redundant queries and fixing N+1 makes sense but the query itself is basically as performant as it gets. Its the cheapest query a database can do essentially
@Classic298 commented on GitHub (Apr 16, 2026):
And since you are logging your database, how long does a single query take for you? If it's anything above 1 ms (just the database) then your deployment is probably subpar. This is an absolutely trivial query and should not take more than tenths of milliseconds.
If you measure it from open webui and you see multiple or hundreds of milliseconds then you have a network issue
@ashm-dev commented on GitHub (Apr 16, 2026):
No slowness — this was from Sentry trace analysis, not a user complaint. Setup is single node, DB local, sub-ms latency.
The query cost was never the point. The redundant callsites and N+1 are.
@Classic298 commented on GitHub (Apr 16, 2026):
Ok. So we have been going in circles.
If your query is sub-ms : Then as I have already said: the query is not the issue and caching is not the solution and as I also already said above, the other fixes at the callsites are good if properly implemented.
So feel free to PR some of the fixes, i just opened one PR also to fix that callsite.
@ashm-dev commented on GitHub (Apr 16, 2026):
Okay, thanks