DEV Community

WaterShow
WaterShow

Posted on Originally published at watershowdigital.com

Fix slow Supabase queries caused by complex RLS policies

Your Supabase app feels fast when you test it alone. Then you launch, users add real data, and pages take seconds to load. You might see timeout errors in your Next.js app or a blank loading screen in your Lovable single page app.

The problem is rarely your frontend code. If the database is slow, it is usually because of Row Level Security (RLS) policies. When you write a database query in your application, Postgres takes the condition defined in your RLS policy and attaches it to your query.

If your RLS policy asks the database to look up a user's role in a different table, Postgres might perform that lookup for every row it scans. A query that returns 50 rows could trigger thousands of background lookups. You can test if RLS is the culprit by running the exact same query using your Supabase service_role key on a private backend. The service_role key bypasses RLS entirely. If the query is fast with the service_role key but slow in the browser, your policies are causing the slowdown.

1. Looking up roles in another table

If your policy uses a SQL statement to check another table, it forces Postgres into a nested loop. The most common case is checking a team members table or a user profiles table to see if the user has access to a document.

To confirm this is the issue, look at your policy definition in the project settings. If you see SQL that includes EXISTS (SELECT 1 FROM team_members WHERE ...) inside the policy, you are doing a table lookup. As your tables grow, this lookup gets slower.

The fix is to inject custom data directly into the user's auth token. Supabase allows you to set custom JWT claims using an auth hook. If you put the user's role or team ID directly into the token when they log in, the RLS policy can read it from memory instead of scanning the disk.

You can read the token data in your policy using a jsonb operator:

(auth.jwt() ->> 'team_id')::uuid = document.team_id
Enter fullscreen mode Exit fullscreen mode

This changes the policy from a database read to a string comparison.

2. Writing complex logic without security definer functions

Sometimes you cannot avoid looking up data in another table. If your permission logic is complex and spans multiple tables, writing it directly in the RLS policy box makes the Postgres query planner struggle to optimize it.

Complicated joins inside the policy definition lead to poor execution plans.

The fix is to move the logic into a dedicated Postgres function and tell the policy to call that function instead. You must create the function using the SECURITY DEFINER keyword. This tells Postgres to run the function with the privileges of the user who created it, bypassing RLS checks for the tables queried inside the function itself. If you do not bypass RLS inside your RLS check, Postgres can get stuck in infinite recursion or extremely slow execution paths.

Create the function in the Supabase SQL editor:

CREATE OR REPLACE FUNCTION user_can_access_project(target_project_id uuid)
RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  RETURN EXISTS (
    SELECT 1 FROM project_members
    WHERE project_id = target_project_id
    AND user_id = auth.uid()
  );
END;
$$;
Enter fullscreen mode Exit fullscreen mode

Then update your RLS policy to be a single function call:

user_can_access_project(project_id)
Enter fullscreen mode Exit fullscreen mode

3. Missing indexes on policy columns

Postgres uses indexes to find rows without reading the entire table. If your RLS policy filters by a column that does not have an index, Postgres has to read every row in the table.

Look at the columns used in your RLS policy. If you have a documents table and the policy requires the user ID to match, Postgres needs to find all documents for that user. Open your table structure in the project settings to check if the column used in the policy has an active index.

If the column is not indexed, run a SQL command to add one:

CREATE INDEX idx_documents_user_id ON documents(user_id);
Enter fullscreen mode Exit fullscreen mode

Adding an index to a column frequently checked by RLS drops query times.

4. Calling auth.uid() too many times

The built-in auth.uid() function is fast, but calling it multiple times in a complex policy can cause the query planner to evaluate the function repeatedly for every row instead of evaluating it once and reusing the result.

You can confirm this by looking at your policy text to see if auth.uid() appears multiple times in different OR and AND conditions.

Wrap the function call in a way that forces Postgres to evaluate it once. The cleanest approach is to use the security definer function method described above. By passing the user ID into a plpgsql variable, you guarantee the value is only computed once per query operation. You can also restructure your SQL to pull the auth.uid() check to the top level of the statement.

Still stuck?

If your queries are still timing out after simplifying your RLS policies, you might have a missing index on your main query or a frontend fetching problem. I can audit your Supabase database and fix the slow queries for you. this is the kind of fix I do.

FAQ

Why is my Supabase query timing out?

Your Row Level Security policies are likely running slow subqueries on every row. Postgres attaches your RLS rules to the query, turning fetches into background lookups.

How do I speed up RLS policies?

Add indexes to the columns your policies filter on. If you must do table lookups, move that logic into a Postgres function created with the SECURITY DEFINER keyword.

Can I read JWT claims in Supabase RLS?

Yes, you can read custom data stored in the auth token. Extract it using the auth.jwt() function in your policy, which avoids reading from the database entirely.


Originally published at watershowdigital.com/blog/fix-slow-supabase-queries-rls-performance. Need a hand with your app or automation? WaterShow Digital fixes and builds it, fixed quote first.

Top comments (1)

Collapse
 
lycore profile image
Lycore Development •

One caveat on the custom JWT claims fix: the claims only change when the token is refreshed, so a user removed from a team keeps access until the old token expires. For anything sensitive we'd keep the lookup but make it cheap. Wrapping it as (select auth.uid()) lets Postgres evaluate it once per query instead of per row, and an index on the membership table's user_id and team_id columns keeps the check fast. Did you compare the EXPLAIN ANALYZE output before and after the change?