High latency with the Supabase client
Find where slow supabase-js requests spend their time (network distance, the query, RLS, or extra round trips) and the fixes that help most on a SupaHost project.
Slow requests usually come from one of three places: the distance between caller and instance, the query itself, or too many round trips. Measure before changing anything, because the fixes are different.
What you see
Requests that take hundreds of milliseconds or several seconds, even for small results. Sometimes only from production, only from serverless functions, or only for some tables.
Why it happens
- Distance. Every request travels to the region your instance runs in. A user in Sydney calling an instance in Frankfurt pays that round trip each time, and a TLS handshake on new connections.
- Server code far from the instance. A Vercel or Lambda function in one region calling an instance in another adds a long hop to every query.
- The query. A missing index, a large
select('*')with big JSON columns, orcount: 'exact'on a big table. - RLS policies that do work per row, such as calling
auth.uid()or a lookup function without wrapping it in aselect. - Many small requests where one would do: fetching a list, then one request per item.
- Auth checks on every request.
supabase.auth.getUser()calls the Auth server each time.
How to fix it
Split network time from server time with curl:
curl -o /dev/null -s \ -w "connect %{time_connect}s tls %{time_appconnect}s first byte %{time_starttransfer}s total %{time_total}s\n" \ "https://app-one.acme.supahost.space/rest/v1/messages?select=id,body&channel_id=eq.7&limit=50" \ -H "apikey: $SUPABASE_ANON_KEY"If
connectis already large, it's distance. If most of the time is betweentlsandfirst byte, it's the query.Time the query in the database. In Studio's SQL editor:
explain (analyze, buffers) select id, body from public.messages where channel_id = 7 order by created_at desc limit 50;A
Seq Scanon a large table usually means a missing index:create index on public.messages (channel_id, created_at desc);Make RLS cheap. Wrap auth calls so Postgres evaluates them once per query, and index the columns your policies filter on:
create policy "Members read channel messages" on public.messages for select to authenticated using (channel_id in (select channel_id from public.channel_members where user_id = (select auth.uid())));Fetch less, in fewer requests. Select only the columns you show, page with
.range(), and embed related rows instead of looping:const { data } = await supabase .from('channels') .select('id, name, messages(id, body, created_at)') .order('created_at', { referencedTable: 'messages', ascending: false }) .limit(20, { referencedTable: 'messages' })For counts on big tables, use
{ count: 'estimated' }instead of'exact'.Reuse the session. On the client, read the session you already have instead of calling
getUser()before every query. On the server you still need to verify the user, but do it once per request, not once per query.Put server code next to the instance. Set your serverless functions' region to the one closest to your SupaHost region.
On SupaHost
- An instance's region is fixed at creation. If most of your users are far from it, see changing an instance's region.
- Projects on an instance share its CPU, memory and Postgres. A heavy import or a runaway query in one project slows the others. Measured read throughput is about 300 requests/s on Builder, 540 on Pro and 950 on Business. Sustained traffic close to that will raise latency; plans and limits has the numbers.
- Projects only have legacy HS256 keys, so
supabase.auth.getClaims()can't verify tokens locally and makes a network call to Auth, just likegetUser(). - There's no connection pooler. Server code that connects with an ORM should keep a small, long-lived pool rather than connecting per request.