Listing Objects a User Can Access at Scale
Checking whether one user may act on one object is cheap in a relationship-based system. Listing everything a user may see is a different operation with different costs, and treating it as a loop of checks is the mistake that makes teams abandon the model. This page is part of the relationship-based access control with OpenFGA guide.
Check and List Are Not the Same Operation
A check starts from a known user, object and relation, and answers a yes-or-no question with a bounded traversal. A list starts from a user and a relation and has to explore the graph, which is a fundamentally larger job.
Use the Store’s List Operation
Tuple stores expose an operation that returns the objects of a given type on which a user holds a relation. Use it, with pagination, rather than assembling the answer yourself.
// Ask the store what is visible, then fetch those rows — not the other way around.
const { objects, continuationToken } = await fga.listObjects({
user: `user:${ctx.userId}`,
relation: "viewer",
type: "document",
consistency: "MINIMIZE_LATENCY", // see the consistency section below
pageSize: 200,
});
const rows = await db.documents
.whereIn("id", objects.map(stripTypePrefix))
.orderBy("updated_at", "desc")
.limit(page.size);
That ordering matters. Asking the store first and filtering the database by the result keeps the authorization work proportional to what the user can see rather than to what exists, and it lets the database do the sorting and paging it is good at.
When the Visible Set Is Too Large
Some users legitimately see tens of thousands of objects — an administrator, a service account, a member of an organisation-wide group. Fetching that entire set to render twenty rows is wasteful, and beyond a certain size it stops being feasible.
The batch-check path needs care to stay correct. Overfetch by a factor that reflects your expected hit rate, check the candidates in one batched call rather than individually, and refill until the page is full or the data is exhausted. Keep the cursor anchored to the database ordering rather than to the filtered results, so paging remains stable when permissions change between requests.
Consistency, Caching and Freshness
Listing is the operation where staleness is most visible, because a user who has just been granted access expects to see the object immediately.
Tuple stores generally offer a consistency preference: minimise latency by reading a possibly stale replica, or require the effects of a recent write. Use the stronger option immediately after a sharing action — carrying the write token through to the next read — and the cheaper one for ordinary browsing. Applying the strong option everywhere makes listing far more expensive than it needs to be.
Cache list results per user and relation with a short lifetime, keyed by a version you bump when that user’s grants change. That gives you a cache that is both effective for repeated navigation and immediately invalidated by the events that matter, which is the same versioning technique used for caching authorization decisions.
Do not cache across users, ever. A list is per-subject by definition, and a cache key that omits the subject is the shortest path to showing one person another’s data.
Modelling to Keep Lists Cheap
Much of listing performance is decided by the authorization model rather than by the query. Relations that reference parent relations — a document’s viewer includes its folder’s viewer — keep the tuple count proportional to real sharing events, which keeps traversals short.
Deep chains are the enemy. Each level of indirection is another hop for every object considered, and a model with five levels will list slowly no matter how it is queried. Two or three levels covers most products; if you find yourself deeper, look for a level that exists for organisational tidiness rather than for access and collapse it.
Watch for relations that fan out enormously — an “everyone in the organisation” group expressed as individual tuples rather than as a wildcard or a group reference. Those inflate both storage and traversal, and they are usually introduced by an import script rather than by a design decision.
Measuring Before Optimising
Listing performance varies enormously between users, so an average is close to meaningless. Measure the distribution and look at the tail, because the tail is where the complaints come from and where the fix is usually a branch rather than a rewrite.
Record, per list request, the size of the visible set returned by the store, the number of store calls made, and the total latency split between the store and the database. Those three numbers identify the failure mode immediately: many store calls means a per-row check that survived review, a large visible set with one call means a broad grant worth short-circuiting, and slow database time with a small set means a missing index on the identifier filter.
Watch the ratio of candidates fetched to rows returned when using the batch-check path. A ratio that drifts upward means your overfetch factor is now wrong for the data, which happens naturally as sharing patterns change, and it shows up as pages that need several refills before they are full.
Compare the numbers across tenants as well as across users. A single customer whose sharing pattern differs from everyone else’s — a workspace where every document is shared with a group containing the whole organisation, for example — will dominate your tail while looking unremarkable in an aggregate, and the fix for them is usually a modelling conversation rather than an engineering one.
Sample slow requests with their user and relation attached, so you can reproduce them. A listing problem is almost always specific to a subset of users, and having a real example beats reasoning about the model in the abstract.
Keep a small set of representative users — a light user, a heavy user, an administrator — and run their list queries as a synthetic check on a schedule, so a regression in the model or an index change shows up as a graph rather than as a support ticket from whoever happens to notice first.
Finally, set a latency budget for listing separately from checking, and alert on it. The two operations have different costs and different fixes, and folding them into one metric hides the one that is degrading.
Frequently Asked Questions
Why not just check each row after fetching a page?
Because it multiplies latency by the page size and produces short pages, wrong counts and unstable cursors — the same problems as any post-filtering approach. If you must check candidates, batch them into one call and refill the page, and treat that as a fallback for very large visible sets rather than the default.
How do I show an accurate total?
Usually you do not. An exact count over a graph-derived set is expensive, and most interfaces need far less precision than they display. Offer “next page” navigation, or an approximate count with a “more than” indicator when the set is large. What you must not do is show the unfiltered total, which discloses the volume of data the user cannot see.
A user was just given access but does not see the object — why?
Replication lag: the list read hit a replica that has not yet seen the new tuple. Carry the consistency token returned by the write into the following read, or request the stronger consistency mode for the request that immediately follows a sharing action. Without that, sharing flows produce intermittent reports that look like permission bugs and are not.
Should search go through the tuple store too?
Search needs the permission filter applied inside the index, not afterwards, or relevance is computed over documents the user cannot see and pages come back short. Index a permission field — the groups or roles that grant visibility — and filter on it at query time, reconciling with the tuple store on a schedule. Post-filtering search results reproduces every problem this page describes.
What if listing is slow only for a few users?
Those users almost certainly hold a broad grant, so detect it explicitly: check whether they hold the relation at the organisation or tenant level and, if so, skip the per-object listing entirely and query the tenant’s rows directly. That single branch usually removes the tail of slow requests, because the slow cases are exactly the ones where the answer is “everything”.
Related
- Relationship-based access control with OpenFGA — the model and consistency behaviour behind these operations.
- Modeling Zanzibar-style relationship tuples — keeping the tuple count proportional to real sharing.
- Filtering list endpoints by permission — the database-side version of the same problem.