Supabase: Canceling Statement Due to Statement Timeout
Error 57014 is almost never the timeout's fault. On Supabase it is usually a policy calling auth.uid() per row against an unindexed column — with the six-policy fix.
canceling statement due to statement timeout is Postgres error 57014: your query ran longer than the statement_timeout set for the role that issued it. On Supabase the API roles get low defaults on purpose — commonly 3s for anon and 8s for authenticated. Raising the limit is the last fix, not the first. The query is nearly always slower than you think, and on Supabase the usual reason is row-level security.
The 30-second triage
Before you touch a setting, establish three facts in this order.
1. Which role hit the wall. The limits differ per role, so the same query can succeed in the SQL editor and fail from your app.
select rolname, rolconfig
from pg_roles
where rolname in ('anon', 'authenticated', 'service_role', 'authenticator');
2. What the query actually does. Not what you think it does — what the planner does with it, with the same role and the same JWT your app sends.
explain (analyze, buffers, settings)
select * from public.download_events
where user_id = '00000000-0000-0000-0000-000000000000'
and created_at > now() - interval '1 hour';
Read the top line's actual time, then look for Seq Scan on anything large.
3. Whether RLS is in the plan. If the table has row-level security on, your where clause is not the whole query. The policy is appended to it, and it runs for every candidate row.
That third one is the difference between debugging this on Supabase and debugging it on plain Postgres, and it is where most 57014s on Supabase come from.
The RLS trap that produces most of these
A policy like this looks harmless:
-- Slow: auth.uid() is re-evaluated for EVERY row scanned
create policy "own downloads" on public.download_events
for select using (auth.uid() = user_id);
auth.uid() reads a setting out of the JWT. Written bare, the planner treats it as a per-row expression, so it is called once per candidate row and the predicate cannot be used to drive an index. On a thousand rows you will never notice. On a million, you get 57014.
Wrapping it in a scalar subquery turns it into an InitPlan — evaluated once, before the scan, and usable as an ordinary equality against an indexed column:
-- Fast: (select auth.uid()) is evaluated ONCE, as an InitPlan
create policy "own downloads" on public.download_events
for select using ((select auth.uid()) = user_id);
That is the entire difference, and it is invisible in the schema diff. This codebase writes all six of its policies that way, across supabase/migrations/0001_commerce_init.sql and 0008_refund_requests.sql:
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
create policy "own deliveries" on public.download_deliveries for select using ((select auth.uid()) = user_id);
create policy "own refund requests" on public.refund_requests for select using ((select auth.uid()) = user_id);
The second half of the fix is the one people skip: index the column the policy filters on. A policy that compares user_id makes user_id a predicate on every single read of that table, including reads whose own where clause is about something else entirely. Five of those six tables carry the matching index:
create index orders_user_id_idx on public.orders (user_id);
create index entitlement_slots_user_id_idx on public.entitlement_slots (user_id);
create index download_events_user_created_idx on public.download_events (user_id, created_at);
create index download_deliveries_user_idx on public.download_deliveries (user_id);
Note the composite on download_events. The hot query filters by user and a time window, so (user_id, created_at) serves both the policy and the range in one index. A plain (user_id) index would still force a filter over every one of that user's historical downloads.
The sixth is refund_requests, whose policy compares user_id while its only indexes are on order_id. That is exactly the shape this section warns about, and it is fine here for one reason worth stating rather than hiding: the table holds one row per refund request, so a sequential scan over it costs nothing. The rule is not "index every policy column reflexively" — it is know which of your policy columns are unindexed, and why that is still acceptable. A table that grows past a few thousand rows without that index is a 57014 waiting for a busy day.
Where the timeout usually comes from
| Cause | What you see | Fix |
|---|---|---|
Bare auth.uid() in a policy | Fine on small tables, 57014 as rows grow | Wrap it: (select auth.uid()) |
| Policy column not indexed | Seq Scan on the table in explain analyze | Index the column the policy compares |
| Bulk insert or update in one statement | Timeout on a migration or an import | Batch it; 100–1,000 rows per statement |
count(*) on a large table | Consistent timeout on a "total" query | Use an estimate from pg_class.reltuples, or keep a counter |
select * pulling large jsonb | Slow in proportion to payload, not row count | Select the columns you need |
| Unbounded query from the client | One user's page times out, others are fine | Always paginate; PostgREST's range header is not optional at scale |
| Query runs fine in the SQL editor | Different role, different timeout, no RLS applied | Reproduce as the API role, with a real JWT |
| A long report on the same connection | Interactive queries time out while it runs | Move it to a scheduled job with its own timeout |
Only now, change the timeout
Once you know the query is as fast as it can be and it is still legitimately long — a report, an export, a nightly aggregation — raise the limit for the narrowest scope that works.
Per function, which is the safest, because the limit travels with the thing that needs it:
alter function public.heavy_report() set statement_timeout = '60s';
Per role, which affects every request that role makes:
alter role authenticated set statement_timeout = '15s';
-- PostgREST caches role settings; reload so it picks them up
notify pgrst, 'reload config';
Resist raising anon. That role answers unauthenticated traffic, and its timeout is the thing standing between an expensive public query and everyone else's latency.
Do the expensive work in one round trip
A pattern worth copying from this codebase, because it fixes a class of slow request rather than one query: when a piece of logic needs to read and write under a condition, do it in a single security definer function instead of a read from the client, a decision in JavaScript, and a write back.
The download endpoint here enforces an hourly rate limit. As a check-then-act pair it was two round trips, two RLS evaluations, and a race (supabase/migrations/0007_atomic_download_rate_limit.sql exists because parallel requests could each read the same count and all pass the check). Collapsed into one RPC it is a single statement's worth of work:
create or replace function public.record_download_within_limit(
p_user_id uuid, p_product_slug text, p_framework text,
p_version text, p_ip inet, p_user_agent text, p_limit int
) returns int
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
recent int;
begin
-- serialize concurrent downloads for THIS user; released on commit
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text, 0));
select count(*)::int into recent
from public.download_events
where user_id = p_user_id
and created_at > now() - interval '1 hour';
if recent >= p_limit then
return -1; -- over limit — record nothing
end if;
insert into public.download_events
(user_id, product_slug, framework, version, ip, user_agent)
values
(p_user_id, p_product_slug, p_framework, p_version, p_ip, p_user_agent);
...
end;
$$;
Two things make this fast as well as correct. The count rides the (user_id, created_at) index, so it touches one user's last hour rather than the table. And security definer means the policy does not re-evaluate inside the function — which is exactly why the pinned search_path and the revoked execute from client roles on the next lines of that migration are not optional. A definer function that skips RLS is a privilege boundary; treat it like one.
Note also what the advisory lock does to your timeout budget: concurrent callers for the same user now wait. That is correct — but it means a slow query inside the lock becomes a queue, and the queue is what hits 57014. Make the query inside a lock the fastest one you have.
Frequently asked questions
Why does the query work in the SQL editor but time out from my app?
The editor runs as a privileged role: a different statement_timeout, and RLS policies that do not apply to it. Reproduce with the API role and a real token, or you are measuring a different query.
Is raising statement_timeout ever the right answer?
Yes — for a genuinely long job, scoped to the function or role that runs it. It is the wrong answer when it is the first thing tried, because it converts a fast failure into a slow one and pushes the cost onto everything sharing the database.
Why do my inserts time out when reads are fine? Usually batch size. A single statement inserting thousands of rows is one long statement. Break it into batches of a few hundred. Check your triggers too — a per-row trigger that queries another table multiplies the work invisibly.
Does this mean RLS is slow? No. RLS is a predicate; a predicate with an InitPlan and an index is as fast as any other equality. What is slow is a policy that calls a function per row against an unindexed column — and that shape is the default one you get by writing the obvious thing.
How do I find which query it was?
The Query Performance report in the dashboard ranks by total time, which is where a repeated 2-second query hides more often than a single 10-second one. pg_stat_statements gives the same data if you prefer SQL.
Related reading
The Supabase service role key post covers the privilege boundary a security definer function sits on, and new row violates row-level security policy is the write-side error from the same policies discussed here.
Templates in this post
ASoc Folio — a developer portfolio with a filterable work grid — ASoc Forge, an AI resume-builder landing page, and ASoc Frame, an AI image-generator page, are typed Next.js and Tailwind source, ready to sit in front of a Supabase project without inheriting its query habits.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
