Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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

CauseWhat you seeFix
Bare auth.uid() in a policyFine on small tables, 57014 as rows growWrap it: (select auth.uid())
Policy column not indexedSeq Scan on the table in explain analyzeIndex the column the policy compares
Bulk insert or update in one statementTimeout on a migration or an importBatch it; 100–1,000 rows per statement
count(*) on a large tableConsistent timeout on a "total" queryUse an estimate from pg_class.reltuples, or keep a counter
select * pulling large jsonbSlow in proportion to payload, not row countSelect the columns you need
Unbounded query from the clientOne user's page times out, others are fineAlways paginate; PostgREST's range header is not optional at scale
Query runs fine in the SQL editorDifferent role, different timeout, no RLS appliedReproduce as the API role, with a real JWT
A long report on the same connectionInteractive queries time out while it runsMove 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.

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.

Keep reading

Tutorial9 min read

Supabase: Check If a User Is Logged In (getUser vs getClaims)

Three methods, one of which trusts an unverified cookie. Counted across this codebase: 14 getUser() calls, 3 getClaims(), and zero getSession().

Read more
Tutorial10 min read

Supabase Deploy: Three Variables Decide Whether Any Page Loads

Eight migrations, seven dashboard settings a migration can't carry, and why a missing Supabase env var takes down 111 product pages instead of one.

Read more
Tutorial9 min read

Supabase Email Rate Limit Exceeded: Why This Codebase Doesn't Fight It

Supabase's built-in email provider caps auth emails project-wide. This codebase relies on that limit by design (R-13) instead of building a bespoke one — and builds one anyway, elsewhere.

Read more