Supabase Edge Functions + Queues: Moving Heavy Work Out of the Request Path
Move slow work off the request path with Supabase Queues and pgmq: enqueue in milliseconds from an Edge Function and drain the job with a worker.

An API endpoint that takes 30 seconds isn't impressive because it did a lot of work. It usually means work happened in the wrong place.
Supabase Queues solves this. Built on the pgmq PostgreSQL extension, it turns slow operations — bulk imports, report generation, embeddings, webhook fan-out — into durable background jobs that a separate worker drains. An Edge Function validates the request and enqueues in milliseconds; another Edge Function does the actual work later.
This guide covers the architecture, working code, and the production details that break things in week three: visibility timeouts, the CPU limit nobody reads about, idempotency, retries, and how to authenticate a cron job against your worker under Supabase's new API key model.
Quick answer
Supabase Queues is a pull-based, PostgreSQL-native message queue built on pgmq. A producer Edge Function validates a request, calls send(), and returns 202 immediately. Supabase Cron then invokes a worker Edge Function on a schedule, which calls read() to claim a batch of messages, processes them, and calls delete() on success. Because pgmq.send() runs inside a normal Postgres transaction, you can enqueue a job and write your business data atomically — something SQS and Redis cannot do.
What Supabase Queues and Edge Functions actually are
Edge Functions are TypeScript functions on a Deno runtime, built for short-lived server-side work: APIs, webhooks, integrations.
Supabase Queues is durable job storage inside your database. Every queue creates two tables in the pgmq schema: pgmq.q_<queue_name> for active messages and pgmq.a_<queue_name> for archived ones. Messages are JSON and stay put until a consumer explicitly deletes or archives them.
The queue does not run your code. It stores work. Something else has to come and get it — that's the part most tutorials skim over, and it's where most implementations go wrong.
The problem: when your API becomes the worker
Picture a CSV import endpoint that parses 100,000 rows, validates them, checks duplicates, inserts customers, sends notifications, and generates a report — all before returning 200.
It works in development. In production, the request stretches past the timeout, memory climbs, one failed external call poisons the whole run, and a client retry duplicates half the work.
Supabase's hosted limits make the ceiling concrete:
Limit**
Value
Memory
256 MB
Wall clock (worker lifetime)
150s Free / 400s Paid
CPU time per request
2s
Request idle timeout
150s (then 504)
That 2-second CPU limit is the one that surprises people. Wall clock is generous because it counts time spent waiting on I/O — a worker can sit for six minutes waiting on API calls. But actual computation is capped at 2 seconds per request. Parsing a large CSV, hashing, image manipulation, or heavy JSON transformation will hit it long before the wall clock matters, and you'll get a 546 WORKER_LIMIT response.
There's a subtler behaviour worth knowing: Edge Functions run in isolates that serve multiple requests, and once an isolate uses roughly half of any resource it stops accepting new requests and retires after finishing what it has. A long-lived worker isolate will be recycled underneath you. Design for it.
The solution: split accepting work from doing work
Instead of one endpoint doing everything, you get two paths.
*** ┌────────────────────────────────────────┐
client │ Postgres │
│ request │ BEGIN │
├──────────────────▶│ INSERT INTO reports ... │
│ │ SELECT pgmq.send('report_jobs', …) │
│ ◀── 202 Accepted │ COMMIT ← atomic, no dual write │
│ │ │
│ │ pgmq.q_report_jobs │
│ └──────────────────▲─────────────────────┘
│ │ read(vt, n) → delete
│ Supabase Cron (every 30s) │
│ │ invoke │
│ ▼ │
│ ┌────────────────┐ drain N │
└─────▶│ worker function│──────────────┘
└────────────────┘*****
Why in-database queuing beats SQS here
This is the strongest technical argument for Supabase Queues, and it's easy to miss.
With SQS or Redis, "save the order and queue the confirmation email" touches two systems that share no transaction. Crash between them and you've either confirmed an order that never emails, or emailed about an order that doesn't exist. This is the dual-write problem, and the standard fix is the transactional outbox pattern: write an event row in the same transaction, then run a relay process that polls the outbox and republishes to the broker.
pgmq.send() is an insert into a Postgres table. Put it in the same transaction as your business write and the message exists if and only if the row commits:
***begin;
insert into reports (id, org_id, status)
values (gen_random_uuid(), $1, 'queued')
returning id into v_report_id;
perform pgmq.send('report_jobs', jsonb_build_object('report_id', v_report_id));
commit;***
No outbox table. No relay. No CDC pipeline. That's a real architectural saving, and it's the reason to reach for Queues over a bolt-on broker when you're already on Postgres.
Prerequisites
- ***A Supabase project on Postgres 15.6.1.143 or later (pgmq requires it)*
- ***The pgmq extension enabled via Integrations → Queues in the Dashboard*
- ***Supabase CLI installed*
- ***Working knowledge of TypeScript and SQL*
Step-by-step implementation
Step 1: Create the queue
Create it from the Dashboard, or in SQL:
*select pgmq.create('report_jobs');
Queue names must be lowercase; hyphens and underscores are allowed. Underscores are the safer habit — the generated table is pgmq.q_report-jobs, which needs quoting in raw SQL if you use hyphens.
Choose Basic (logged, durable) unless you have measured a write bottleneck. Unlogged trades durability for throughput; messages can be lost on crash. The archive table stays logged either way.
If you create the queue from the Dashboard, leave the RLS option enabled — it saves you writing policies on the queue tables by hand.
Step 2: Decide whether the client touches the queue at all
By default, queues are SQL-only and not exposed over the Data API. For server-side work — which is what you want here — leave it that way and let your Edge Function use the service credentials.
Only enable Queues → Settings → Expose Queues via PostgREST if browser or mobile clients need direct access. That creates a pgmq_public schema wrapping a subset of pgmq, and you then owe it two things: RLS policies on every pgmq.q_* table, and per-role function grants.
Operation
Permissions required
send, send_batch
Select + Insert
read, pop
Select + Update
archive, delete
Select + Delete
Never grant postgres or service_role to client-side roles.
Step 3: Enqueue from a producer function
***import { createClient } from "npm:@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{ db: { schema: "pgmq_public" } },
);
Deno.serve(async (req) => {
if (req.method !== "POST") {
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
try {
const { reportId, organizationId } = await req.json();
if (!reportId || !organizationId) {
return Response.json(
{ error: "reportId and organizationId are required" },
{ status: 400 },
);
}
const jobId = crypto.randomUUID();
const { error } = await supabase.rpc("send", {
queue_name: "report_jobs",
message: { jobId, reportId, organizationId, type: "generate_report" },
sleep_seconds: 0, // delay before the message becomes visible
});
if (error) {
console.error("enqueue_failed", error);
return Response.json({ error: "Failed to queue report" }, { status: 500 });
}
return Response.json({ jobId, status: "queued" }, { status: 202 });
} catch (error) {
console.error("bad_request", error);
return Response.json({ error: "Invalid request" }, { status: 400 });
}
});***
Note the 202 Accepted — you're acknowledging work, not completing it. Also note that this function does not generate the report. That's the entire point.
A parameter-naming trap: in pgmq_public.send, sleep_seconds is a delay before the message becomes visible. In pgmq_public.read, sleep_seconds is the visibility timeout. Same name, different meaning, adjacent functions. Read that sentence twice.
Step 4: Understand the visibility timeout before you write the worker
When a consumer reads a message, the message isn't deleted — it becomes invisible to other consumers for a set number of seconds.
***read Job A → Job A invisible for vt seconds
│
├── worker succeeds → delete() → gone
│
└── worker crashes → vt expires → Job A visible again***
That reappearance is what makes the queue reliable, and it's why delivery is at-least-once, not exactly-once. Supabase's marketing copy says "exactly once within a visibility window," which is true but easy to misread: if your worker dies after doing the work but before calling delete(), the job runs twice. Plan for it.
The rule: your visibility timeout must exceed your worst-case processing time, including slow external APIs. Set it too low and your own worker hands its in-progress job to a second worker.
For jobs whose duration you can't predict, extend the lease mid-flight with pgmq.set_vt().
Step 5: Write a time-budgeted worker
***import { createClient } from "npm:@supabase/supabase-js@2";
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
{ db: { schema: "pgmq_public" } },
);
const QUEUE = "report_jobs";
const VISIBILITY_TIMEOUT = 120; // seconds — must exceed worst-case job time
const BATCH_SIZE = 5;
const TIME_BUDGET_MS = 100_000; // stop well before the wall-clock limit
const MAX_ATTEMPTS = 5;
Deno.serve(async () => {
const startedAt = Date.now();
const { data: messages, error } = await supabase.rpc("read", {
queue_name: QUEUE,
sleep_seconds: VISIBILITY_TIMEOUT, // this is the visibility timeout, NOT a poll wait
n: BATCH_SIZE,
});
if (error) {
console.error("queue_read_failed", error);
return Response.json({ error: error.message }, { status: 500 });
}
if (!messages?.length) {
return Response.json({ processed: 0 }, { status: 200 });
}
let processed = 0;
for (const msg of messages) {
if (Date.now() - startedAt > TIME_BUDGET_MS) {
console.warn("time_budget_reached", { remaining: messages.length - processed });
break; // leave the rest; vt expiry re-delivers them
}
// Poison-message handling: pgmq has no built-in dead-letter queue.
if (msg.read_ct > MAX_ATTEMPTS) {
await supabase.rpc("send", {
queue_name: ${QUEUE}_dead,
message: { original: msg.message, attempts: msg.read_ct },
sleep_seconds: 0,
});
await supabase.rpc("archive", { queue_name: QUEUE, message_id: msg.msg_id });
continue;
}
try {
await processJob(msg.message, msg.msg_id);
await supabase.rpc("delete", { queue_name: QUEUE, message_id: msg.msg_id });
processed++;
} catch (err) {
// Don't delete. Let the visibility timeout re-deliver it.
console.error("job_failed", {
msgId: String(msg.msg_id),
attempt: msg.read_ct,
error: err instanceof Error ? err.message : String(err),
});
}
}
return Response.json({ processed }, { status: 200 });
});***
Four things here that the naive version misses: the visibility timeout is real rather than zero, the loop has a time budget so a slow batch doesn't get killed mid-job, failures are left on the queue instead of swallowed, and messages that have failed too many times get routed to a dead-letter queue instead of retrying forever.
*Verify the parameter names in your own project. Supabase's API reference documents delete(queue_name, message_id), while its Edge Functions guide uses msg_id. These have drifted apart, and a mismatch throws PGRST202. Check what your project actually has:***
*sql***
*select p.proname, pg_get_function_arguments(p.oid)***
*from pg_proc p***
*join pg_namespace n on n.oid = p.pronamespace***
*where n.nspname = 'pgmq_public';***
Multiple workers can safely read the same queue at once: pgmq uses FOR UPDATE SKIP LOCKED internally, so concurrent readers claim disjoint sets of messages rather than fighting over the same rows.
Step 6: Trigger the worker with Cron
Nothing calls your worker unless you arrange it. Supabase Cron (a UI over pg_cron) can invoke an Edge Function directly, and it supports sub-minute schedules.
If you're writing the SQL by hand, store credentials in Vault rather than pasting keys into a cron definition:
***select cron.schedule(
'drain-report-jobs',
'30 seconds',
$$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
|| '/functions/v1/process-report-jobs',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'worker_secret_key')
),
body := '{}'::jsonb,
timeout_milliseconds := 10000
);
$$
);***
This is the part that recently changed. Supabase has moved to publishable and secret API keys, and secret keys are not JWTs — so sending one as Authorization: Bearer … gets rejected. Service-to-service callers now send the secret key on the apikey header instead. On the function side, set verify_jwt = false and validate the key explicitly:
***import { withSupabase } from "npm:@supabase/server";
export default {
fetch: withSupabase({ auth: "secret" }, async (_req, ctx) => {
// ctx.supabaseAdmin bypasses RLS
return Response.json({ ok: true });
}),
};***
Use auth: 'secret:worker' to accept only one named key. Most tutorials still show Authorization: Bearer <service_role_key>; that works with legacy keys and will stop working when you migrate.
One more gotcha: pg_net is fire-and-forget. The cron job reports success even if your function returned 500 or timed out. Responses land in net._http_response and are retained for about six hours:
***select id, status_code, error_msg, created
from net._http_response
order by created desc
limit 20;***
A second example: embeddings via database trigger
Not every job starts with an HTTP request. When the trigger is a data change, enqueue from the database directly — this is where transactional enqueue pays off.
***create or replace function queue_embedding_job()
returns trigger language plpgsql as $$
begin
perform pgmq.send('embed_jobs', jsonb_build_object('doc_id', new.id));
return new;
end;
$$;
create trigger documents_embed_on_insert
after insert on documents
for each row execute function queue_embedding_job();***
The message is committed with the row, in the same transaction. A document can never exist without a pending embedding job.
The worker is the same shape as above, with one adjustment: embedding APIs are slow and CPU-cheap, so keep batches small (5–10) and set the visibility timeout above the provider's worst-case latency. This is the architecture behind Supabase's own automatic embeddings pipeline.
Common problems
permission denied for table q_report_jobs
You exposed queues over PostgREST but skipped RLS or the function grants. Remember read needs Select and Update — Select alone fails, because reading mutates the visibility timestamp.
PGRST202: Could not find the function pgmq_public.read(n, queue_name) in the schema cache
Two causes. Either pgmq_public isn't in your exposed schemas list (Settings → API → Exposed schemas), or you omitted a required parameter. sleep_seconds on read is not optional despite reading like it is — the hint in the error names the full signature it expected.
The same job runs twice
Your visibility timeout is shorter than your processing time, so the message became visible again while the first worker was still running. Raise the timeout, extend it mid-job with set_vt(), and make the work idempotent — because at-least-once delivery means this will happen eventually regardless.
546 WORKER_LIMIT or "wall clock time limit reached"
You hit the 2s CPU ceiling or the 400s worker lifetime. Shrink the batch, move computation into SQL where possible, and split large jobs into more messages instead of longer ones.
The worker never runs
Queues are pull-based. Check that the cron job exists and is firing (select * from cron.job_run_details order by start_time desc limit 10;), then check net._http_response for the actual HTTP outcome — a 401 there usually means the auth header issue described above.
Best practices
- Keep messages small. Store an ID, not the payload. Fetch the real data in the worker. A queue is not a second database.
- Make workers idempotent. Enforce it with a unique constraint on a processed_jobs(job_id) table checked inside the work transaction, so a re-delivery is a no-op rather than a double charge.
- Set visibility timeouts above worst-case duration, external API latency included.
- Chunk aggressively. 2,000 jobs of 500 records beat one job of 1,000,000. Use send_batch to enqueue them in one call.
- Build a dead-letter queue on day one. pgmq has no native DLQ — route on read_ct as shown above, or poison messages will retry forever.
- Monitor queue depth, not just errors. select * from pgmq.metrics('report_jobs'); gives you queue_length and oldest_msg_age_sec. Alert on oldest message age; it catches a stalled worker that error logs won't.
- Track job state separately. The queue knows about pending work. Your users need a jobs table with queued / processing / completed / failed.
Performance, security, and scale
Performance. pgmq benchmarks well past most application needs — Tembo measured over 7,000 messages/second single-message and 30,000+ batched on a 16 vCPU node. Your bottleneck will be your own queries long before it's the queue. Adding workers won't help if the work does unindexed table scans; it just moves the bottleneck into Postgres. Watch connection count too — many concurrent Edge Function instances need Supavisor in transaction mode.
Queue tables churn hard: every read updates a row. On high-throughput queues, delete rather than archive, and make sure autovacuum is keeping up with dead tuples.
Security. Keep queue operations server-side by default. If you must expose them, RLS plus function grants are both required — neither alone is sufficient. Store keys in Vault, never inline in cron SQL where they sit in plain text. And validate every message in the worker: a message from your own queue is still untrusted input, because it was written by code that may have had a bug.
How it compares
Supabase Queues
AWS SQS
Redis / BullMQ
Inngest / Trigger.dev
Transactional enqueue with app data
Yes, native
No — needs outbox
No
No
Delivery guarantee
At-least-once
At-least-once
At-least-once
At-least-once
Dead-letter queue
Manual (read_ct)
Native redrive
Native
Native
Retention
Until deleted
14 days max
Until processed
Managed
Observability
Dashboard + SQL
CloudWatch
Bull Board
Rich UI
Ops overhead
None (it's your DB)
Low
High (run Redis)
Low
Cost
Included in DB
Per request
Server cost
Per run
When should you use Supabase Queues?
Use it when you're already on Supabase or Postgres, the work is genuinely asynchronous, jobs must survive a crash, and you'd rather not operate separate queue infrastructure. Typical fits: CSV imports, bulk email, webhook processing, document generation, embeddings, scheduled reports, data sync.
Consider something else when you need very high-throughput event streaming, priority routing, complex multi-step workflow orchestration with a visual history, or jobs whose computation genuinely exceeds what a 2-second CPU budget can be chunked into. At that point a dedicated worker fleet or a durable-execution platform earns its complexity — and you can still enqueue transactionally in Postgres and relay outward.
FAQ
Q. How do I run background jobs in Supabase?
- Enqueue with pgmq.send(), then drain the queue from an Edge Function invoked on a schedule by Supabase Cron. There is no always-on worker process; you provide the invocation.
Q. Does a Supabase Queue automatically run my Edge Function?
- No. Queues are pull-based. Something must invoke the consumer — Cron, a database trigger via pg_net, or a manual functions.invoke call.
Q. What's the maximum runtime for a Supabase Edge Function?
- A worker stays alive up to 150 seconds on Free and 400 seconds on paid plans, but CPU time is capped at 2 seconds per request and the request itself must respond within 150 seconds or return 504.
Q. Can a queue message be processed more than once?
- Yes. Delivery is at-least-once. If a worker crashes after doing the work but before deleting the message, it will be re-delivered when the visibility timeout expires. Idempotency is a design requirement, not an optimisation.
Q. How do I retry failed jobs or set up a dead-letter queue?
- Failed jobs retry automatically — just don't delete the message. For a DLQ, check read_ct against a threshold and forward the message to a separate queue, then archive the original. pgmq has no built-in DLQ.
Q. Is EdgeRuntime.waitUntil() the same as a queue?
- No. waitUntil() keeps a task running in the current function instance after the response is sent. It's still bound by the same CPU, memory, and wall-clock limits, and if the isolate is recycled the work vanishes with no retry and no record. Use it for fire-and-forget side effects; use a queue when losing the work matters.
Q. Can Supabase Queues replace Redis or Kafka?
- For most Postgres-centric applications, yes. High-throughput event streaming and specialised messaging semantics still justify dedicated systems.
Conclusion
The goal of background processing isn't to make a function run longer. It's to change the shape of the workload.
Let the request path authenticate, validate, enqueue, and return. Let a worker claim a bounded batch, process it idempotently, and delete or archive each message. Get the visibility timeout right, budget your worker's time, and build the dead-letter path before you need it.
Supabase Queues is a strong fit when you're already on Postgres, because the enqueue joins the same transaction as your data — an architectural advantage that external brokers can't offer without an outbox. Reach for dedicated infrastructure when throughput or orchestration genuinely demands it, not before.
The best sign your architecture is right: the request path has very little to do.
Need help building scalable Supabase applications?
If your endpoints are outgrowing synchronous request handling, or you're designing background processing for imports, integrations, AI workloads, or reporting, the architecture decisions matter more than the infrastructure choice.
Internal links to place when publishing
- Add links here for other referenced docs
External references
- ***Supabase Queues documentation***
- ***Queues API reference***
- ***Consuming messages with Edge Functions***
- ***Edge Functions limits***
- ***Securing Edge Functions***
- ***Scheduling Edge Functions***
- ***Supabase Cron***
- ***pgmq on GitHub***
SEO

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.


