DEV Community

Cover image for Supabase Queues in Production: Dead-Letter Queues, Retries, and Poison Messages with pgmq
Michelle Wiginton
Michelle Wiginton

Posted on

Supabase Queues in Production: Dead-Letter Queues, Retries, and Poison Messages with pgmq

Supabase Queues gives you a durable message queue with one migration and zero new infrastructure. The official docs walk you through creating a queue, sending a message, and reading it back, and then they stop.

Here's what they don't tell you, and it's the part that matters in production:

TL;DR: pgmq (the extension behind Supabase Queues) has no built in dead letter queue, no retry limit, and no failure alerting. A message that always fails will be retried forever. You build all three yourself: use the read_ct column as a free retry counter, divert messages that exceed your retry budget to a second queue (your DLQ), and watch pgmq.metrics() on a schedule to alert when something is stuck. This article contains the complete SQL and worker code to do it.

I recently moved my Next.js dashboard logic into Postgres and liked that approach enough to try it somewhere new: this time I pushed background jobs into the database on a fresh, standalone project. This post is everything I had to figure out that the docs skipped: the five failure modes that will bite you, and the fix for each one, tested end to end on a real Supabase project.

Why put your job queue in Postgres at all?

The traditional background job setup for a Next.js + Supabase app is a separate queue service (Redis + BullMQ, or SQS) plus a long running worker on Railway or a VPS. That's two more services to deploy, monitor, and pay for.

pgmq takes the position that for most apps, the queue can just be Postgres tables and functions. Your messages live in the same database you already back up. Enqueueing can join a transaction with the rest of your write. And "deploying the queue" is a migration file.

The tradeoff is that pgmq gives you the storage and delivery semantics of a queue, and almost none of the operational conveniences of a managed one. That's the gap this article fills.

The 60 second happy path

For completeness, here's the minimal setup. On Supabase, enable the extension (Dashboard → Integrations → Queues, or SQL):

create extension if not exists pgmq;

-- Our running example: a queue of transactional email jobs
select pgmq.create('email_jobs');
Enter fullscreen mode Exit fullscreen mode

Send, read, and archive:

-- Enqueue a job
select pgmq.send(
  queue_name => 'email_jobs',
  msg        => '{"template": "welcome", "user_id": "8f3c2a10-...."}'::jsonb
);

-- Read up to 5 messages; each becomes invisible to other readers for 60 seconds
select * from pgmq.read(
  queue_name => 'email_jobs',
  vt         => 60,
  qty        => 5
);

-- Done with message 1? Archive it (moves it to an archive table)
select pgmq.archive(queue_name => 'email_jobs', msg_id => 1);
Enter fullscreen mode Exit fullscreen mode

That's the whole API surface you need for now: send, read, archive, plus delete, pop, set_vt, and metrics, which we'll get to. The official quickstart covers the dashboard UI if you prefer clicking.

Now for the parts that aren't in the quickstart.

The mental model: what pgmq actually tracks

Every queue is a table (pgmq.q_email_jobs) plus an archive table (pgmq.a_email_jobs). Here's what read() actually hands back (psql expanded display):

-[ RECORD 1 ]+---------------------------------------------------------------------------
msg_id       | 7
read_ct      | 1
enqueued_at  | 2026-07-07 13:53:49.094925-05
last_read_at | 2026-07-07 13:53:49.145811-05
vt           | 2026-07-07 13:54:49.145811-05
message      | {"user_id": "8f3c2a10-4b6d-4e21-9c58-2f7a1d0e6b33", "template": "welcome"}
headers      |
Enter fullscreen mode Exit fullscreen mode

(The last_read_at and headers columns are recent additions to pgmq; if your platform bundles an older release you won't see them, and nothing in this article depends on them.)

Three of these fields carry the entire failure-handling story:

msg_id: unique per queue, assigned at send time.

vt: the visibility timeout, stored as a timestamp. When you read() a message with vt => 60, pgmq sets this to now() + 60 seconds. Until that moment passes, no other read() call will return this message. If your worker finishes, it archives or deletes the message and the story ends. If your worker crashes, the timeout expires and the message simply becomes visible again. Nothing "fails"; the message just comes back.

read_ct: increments every time the message is read. First delivery is read_ct = 1.

That last field is the whole trick. pgmq doesn't know whether your worker succeeded or failed; it only knows how many times a message has been handed out. A message that has been read six times and is still in the queue has failed (or crashed a worker) five or six times. read_ct is a free retry counter, and everything below builds on it.

Failure mode #1: the infinite retry loop

Here's the scenario the docs never mention. Enqueue a message your worker can't handle:

-- template is null; the worker will throw on this every time
select pgmq.send('email_jobs', '{"template": null}'::jsonb);
Enter fullscreen mode Exit fullscreen mode

Your worker reads it, throws, and doesn't archive it. Sixty seconds later the visibility timeout expires and the message is back. The worker reads it again. Throws again. Forever.

Watch it happen:

select msg_id, read_ct, enqueued_at
from pgmq.q_email_jobs;
Enter fullscreen mode Exit fullscreen mode
 msg_id | read_ct |          enqueued_at
--------+---------+-------------------------------
      2 |      14 | 2026-07-07 13:51:25.886937-05
(1 row)
Enter fullscreen mode Exit fullscreen mode

Fourteen deliveries, fourteen failures, and no error surfaced by pgmq.

This is called a poison message, and a single one consumes capacity on every worker run indefinitely. pgmq has no max_retries setting, so it will keep re-delivering the message until you intervene.

The fix: build a dead-letter queue with read_ct

Does pgmq have a built-in dead-letter queue? No, and this isn't a workaround I invented. It's how pgmq's own creators run it in production: check read_ct on read, and when it exceeds a threshold, move the message to a second queue instead of processing it.

First, create the DLQ. It's just another queue:

select pgmq.create('email_jobs_dlq');
Enter fullscreen mode Exit fullscreen mode

Then wrap pgmq.read() in a function that enforces your retry budget. This is the centerpiece of the whole setup:

create or replace function public.read_with_dlq(
  p_queue_name  text,
  p_vt          integer default 60,
  p_qty         integer default 5,
  p_max_read_ct integer default 5
)
returns setof pgmq.message_record
language plpgsql
security definer
set search_path = ''
as $$
declare
  msg pgmq.message_record;
begin
  for msg in
    select * from pgmq.read(p_queue_name, p_vt, p_qty)
  loop
    if msg.read_ct > p_max_read_ct then
      -- Poison message: divert to the DLQ with context, then archive the original
      perform pgmq.send(
        queue_name => p_queue_name || '_dlq',
        msg => jsonb_build_object(
          'original_msg_id', msg.msg_id,
          'read_ct',         msg.read_ct,
          'enqueued_at',     msg.enqueued_at,
          'dead_lettered_at', now(),
          'payload',         msg.message
        )
      );
      perform pgmq.archive(p_queue_name, msg.msg_id);
    else
      return next msg;
    end if;
  end loop;
end;
$$;

-- Only the backend worker should call this
revoke execute on function public.read_with_dlq(text, integer, integer, integer)
  from public, anon, authenticated;
grant execute on function public.read_with_dlq(text, integer, integer, integer)
  to service_role;
Enter fullscreen mode Exit fullscreen mode

The retry math is worth spelling out, because read_ct includes the current read. With p_max_read_ct = 5: reads 1 through 5 hand the message to your worker (five real processing attempts). On read 6, read_ct = 6 > 5, and the message is diverted without being processed. So p_max_read_ct literally means "maximum processing attempts."

I verified this by sending the same poisoned payload through a fresh cycle: calls 1 through 5 to read_with_dlq() each returned the message, and call 6 returned zero rows: the message had been diverted. Here's what landed in the DLQ (expanded display):

-[ RECORD 1 ]---------------------------------------------------------------
msg_id  | 1
message | {"payload": {"template": null}, "read_ct": 6,
          "enqueued_at": "2026-07-07T18:51:39.838618+00:00",
          "original_msg_id": 3,
          "dead_lettered_at": "2026-07-07T18:51:40.175338+00:00"}
Enter fullscreen mode Exit fullscreen mode

Two design notes. I wrap the original payload in an envelope with read_ct and timestamps so that each dead message carries its own history, which makes the DLQ far easier to investigate; the record above is exactly what that looks like. And I archive() the original rather than delete() it, so the main queue's archive table remains a complete record of everything that ever passed through.

Reprocessing dead letters after you fix the underlying bug is a two-liner per message:

-- Inspect what's dead
select msg_id, message from pgmq.q_email_jobs_dlq;

-- Re-enqueue the original payload, then archive the DLQ entry
select pgmq.send('email_jobs', (message -> 'payload')::jsonb)
from pgmq.q_email_jobs_dlq
where msg_id = 1;

select pgmq.archive('email_jobs_dlq', 1);
Enter fullscreen mode Exit fullscreen mode

Note that the re-enqueued message gets a fresh msg_id and a fresh read_ct of zero, so its retry budget resets. That matters for idempotency, which we'll hit in failure mode #3.

Failure mode #2: the visibility timeout is a bet, and you can lose it both ways

Every read() requires a vt, and that number is a bet on how long processing takes.

Bet too low and a slow job's message becomes visible again while the first worker is still processing it. A second worker picks it up, and now the same job is running twice concurrently. If the job sends an email, two emails go out. This isn't a crash (nothing errors), which makes it miserable to debug.

Bet too high and a genuinely crashed worker's message stays invisible for the full window. With vt => 3600, a job that died at second 5 waits 59 more minutes before anything retries it.

The rule of thumb: set vt to your worst realistic processing time (p99, not average) plus a small buffer. For a batch worker, remember the clock starts at read time, so a message processed last in a batch of ten needs the timeout to cover the nine jobs ahead of it.

For jobs with rare-but-legitimate long runs, don't inflate the global timeout; extend it per message while you work. pgmq.set_vt pushes a specific message's visibility further into the future:

-- "I'm still working on message 42; give me 120 more seconds"
select pgmq.set_vt('email_jobs', 42, 120);
Enter fullscreen mode Exit fullscreen mode

Call it from your worker as a heartbeat during long operations, and you get short timeouts (fast crash recovery) and safe long jobs.

Failure mode #3: "exactly-once" doesn't mean what you think

Supabase's marketing says messages are delivered exactly once, but read the fine print: exactly once within a customizable visibility window. Across a message's lifetime, pgmq is an at-least-once system. A worker that crashes after doing the work but before archiving the message guarantees a redelivery. So does a too-short vt. So does re-enqueueing from the DLQ.

The consequence: your job handler must be idempotent, safe to run twice with the same input. For jobs that write to your own database, idempotency is often free (insert ... on conflict do update is naturally re-runnable). For jobs with external side effects (emails, payment API calls, push notifications), you need a claim check. Postgres makes this trivially cheap:

create table public.processed_jobs (
  idempotency_key text primary key,
  processed_at    timestamptz not null default now()
);

create or replace function public.claim_job(p_key text)
returns boolean
language sql
security definer
set search_path = ''
as $$
  insert into public.processed_jobs (idempotency_key)
  values (p_key)
  on conflict (idempotency_key) do nothing
  returning true;
$$;

revoke execute on function public.claim_job(text) from public, anon, authenticated;
grant execute on function public.claim_job(text) to service_role;
Enter fullscreen mode Exit fullscreen mode

The returning true only fires when the insert actually happens. On a duplicate, the function returns null, which is falsy in your worker's JavaScript, so the check reads naturally: if (claimed) { doTheWork(); }.

One subtlety: put the idempotency key in the message payload at send time (something business-meaningful like email:welcome:8f3c2a10:2026-07-07), not derived from msg_id. DLQ reprocessing assigns a new msg_id, and a key based on it would happily let the side effect fire twice.

And an honest tradeoff: claim-before-work means a crash between claiming and finishing leaves that job permanently done-but-not-done. For emails, that's the right failure direction: at-most-once beats double-sending. For jobs where a miss is worse than a duplicate, claim after the work instead and treat the table purely as dedup.

Failure mode #4: overlapping workers (and the advisory-lock trap)

The standard Supabase architecture is pg_cron invoking an Edge Function worker every minute. What happens when a run takes longer than a minute? The next tick fires and now two workers are draining the queue concurrently.

Often, that's fine. The visibility timeout already guarantees two workers can't read the same message at the same time, so overlap just means the queue drains faster. If your jobs are idempotent (see above) and your downstream can take the parallelism, let it happen.

If you genuinely need single-flight (say the job hammers a rate-limited third-party API), the tempting answer is pg_try_advisory_lock(). Don't use it through the Data API. Advisory locks are session-scoped, and when your worker calls it via PostgREST/RPC, the "session" is a pooled connection that goes back into the pool still holding the lock. Your queue silently stops processing and nothing in any log tells you why.

The pooler-safe alternative is a lease row claimed with one atomic statement:

create table public.worker_leases (
  worker_name text primary key,
  lease_until timestamptz not null
);

create or replace function public.claim_worker_lease(
  p_worker  text,
  p_seconds integer default 55
)
returns boolean
language sql
security definer
set search_path = ''
as $$
  insert into public.worker_leases (worker_name, lease_until)
  values (p_worker, now() + make_interval(secs => p_seconds))
  on conflict (worker_name) do update
    set lease_until = excluded.lease_until
    where worker_leases.lease_until < now()
  returning true;
$$;

revoke execute on function public.claim_worker_lease(text, integer) from public, anon, authenticated;
grant execute on function public.claim_worker_lease(text, integer) to service_role;
Enter fullscreen mode Exit fullscreen mode

If another worker holds an unexpired lease, the where clause blocks the update, no row returns, and the caller gets null. The lease expires on its own, so a crashed worker can't deadlock the system. Set the lease duration just under your cron interval (55 seconds for a every-minute tick) and each tick either claims the minute or exits immediately.

Failure mode #5: nobody finds out

Here's the operational difference between pgmq and SQS that will actually hurt you: when a managed queue's DLQ receives a message, you can wire an alarm. When a pg_cron-driven pgmq worker fails, you get a row in a log table that no one is looking at. Skipped runs are not retried. If your project gets paused or hits its connection ceiling, every schedule stops, silently.

The fix is to make the database check on itself. pgmq.metrics() returns everything you need:

select * from pgmq.metrics('email_jobs');
Enter fullscreen mode Exit fullscreen mode
 queue_name | queue_length | newest_msg_age_sec | oldest_msg_age_sec | total_messages |          scrape_time          | queue_visible_length
------------+--------------+--------------------+--------------------+----------------+-------------------------------+----------------------
 email_jobs |            3 |                  3 |                 57 |              6 | 2026-07-07 13:52:49.777577-05 |                    3
(1 row)
Enter fullscreen mode Exit fullscreen mode

(Recent pgmq releases also report queue_visible_length, messages that are currently visible rather than locked mid-read by a worker. Older versions omit that column.)

Two numbers matter. DLQ queue_length above zero means jobs are dying. oldest_msg_age_sec on the main queue climbing past your worst-case latency means the worker itself is stuck or gone: the failure that catches everyone, because no individual job errored.

Wire both to a webhook (Slack, Discord, anything that makes a phone buzz) with pg_net, on a schedule. Store the webhook URL in Supabase Vault first:

select vault.create_secret('https://hooks.slack.com/services/XXX/YYY/ZZZ', 'alerts_webhook');
Enter fullscreen mode Exit fullscreen mode
create or replace function public.check_queue_health()
returns void
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_dlq_depth  bigint;
  v_oldest_age integer;
  v_webhook    text;
begin
  select queue_length       into v_dlq_depth  from pgmq.metrics('email_jobs_dlq');
  select oldest_msg_age_sec into v_oldest_age from pgmq.metrics('email_jobs');

  if coalesce(v_dlq_depth, 0) > 0 or coalesce(v_oldest_age, 0) > 900 then
    select decrypted_secret into v_webhook
    from vault.decrypted_secrets
    where name = 'alerts_webhook';

    perform net.http_post(
      url     := v_webhook,
      headers := '{"Content-Type": "application/json"}'::jsonb,
      body    := jsonb_build_object(
        'text', format(
          'Queue alert. DLQ depth: %s, oldest pending message: %ss',
          coalesce(v_dlq_depth, 0),
          coalesce(v_oldest_age, 0)
        )
      )
    );
  end if;
end;
$$;

select cron.schedule('queue-health-check', '*/5 * * * *', 'select public.check_queue_health()');
Enter fullscreen mode Exit fullscreen mode

Fifteen minutes of setup, and your Postgres-native queue now has better failure visibility than most teams' Redis setup.

The worker: an Edge Function that ties it together

Everything above composes into one worker. pg_cron triggers it every minute; it claims the lease, reads through the DLQ wrapper, claims each job's idempotency key, does the work, and archives.

First, the worker needs a way to archive messages over RPC. The pgmq schema isn't exposed through the Data API, so give it a minimal wrapper:

create or replace function public.queue_archive(p_queue_name text, p_msg_id bigint)
returns boolean
language sql
security definer
set search_path = ''
as $$
  select pgmq.archive(p_queue_name, p_msg_id);
$$;

revoke execute on function public.queue_archive(text, bigint) from public, anon, authenticated;
grant execute on function public.queue_archive(text, bigint) to service_role;
Enter fullscreen mode Exit fullscreen mode

The Edge Function itself:

import { createClient } from "jsr:@supabase/supabase-js@2";

const QUEUE = "email_jobs";
const BATCH_SIZE = 10;
const VT_SECONDS = 60;
const MAX_ATTEMPTS = 5;

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

Deno.serve(async (req) => {
  // Only pg_cron (which sends the service role key) may invoke this
  const auth = req.headers.get("Authorization");
  if (auth !== `Bearer ${Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")}`) {
    return new Response("Unauthorized", { status: 401 });
  }

  // Single-flight: skip this tick if the previous run is still going
  const { data: gotLease } = await supabase.rpc("claim_worker_lease", {
    p_worker: "email_jobs_worker",
    p_seconds: 55,
  });
  if (!gotLease) {
    return Response.json({ skipped: "lease held by another run" });
  }

  // Read a batch; poison messages are diverted to the DLQ inside this call
  const { data: messages, error } = await supabase.rpc("read_with_dlq", {
    p_queue_name: QUEUE,
    p_vt: VT_SECONDS,
    p_qty: BATCH_SIZE,
    p_max_read_ct: MAX_ATTEMPTS,
  });
  if (error) {
    return new Response(error.message, { status: 500 });
  }

  let processed = 0;
  let failed = 0;

  for (const msg of messages ?? []) {
    // Payloads enqueued without a key (e.g. ad-hoc sends) fall back to msg_id
    const key = msg.message.idempotency_key ?? `msg:${msg.msg_id}`;
    try {
      const { data: claimed } = await supabase.rpc("claim_job", { p_key: key });
      if (claimed) {
        await sendEmail(msg.message); // your actual job logic
      }
      // Archive whether we did the work or a previous run already had:
      // either way, this message is finished.
      await supabase.rpc("queue_archive", {
        p_queue_name: QUEUE,
        p_msg_id: msg.msg_id,
      });
      processed++;
    } catch (err) {
      // Deliberately do nothing: the visibility timeout will expire,
      // read_ct will increment, and the message retries next run.
      console.error(`msg ${msg.msg_id} failed:`, err);
      failed++;
    }
  }

  return Response.json({ processed, failed });
});

async function sendEmail(payload: Record<string, unknown>) {
  if (!payload.template) {
    throw new Error("template missing; this message can never succeed");
  }
  // ...render the template and call your email provider (Resend, Postmark, SES)
}
Enter fullscreen mode Exit fullscreen mode

Notice what the catch block does: nothing. That's the design. Failure handling isn't code in the worker; it's the visibility timeout, read_ct, and the DLQ threshold doing their jobs. The worker's only responsibilities are to do the work and archive on success.

Schedule it with pg_cron, pulling the URL and key from Vault rather than hardcoding them (the cron.job table is readable, so a hardcoded service role key in a cron command is a leak):

select vault.create_secret('https://YOUR-PROJECT-REF.supabase.co', 'project_url');
select vault.create_secret('YOUR-SERVICE-ROLE-KEY', 'service_role_key');

select cron.schedule(
  'drain-email-jobs',
  '* * * * *',
  $$
  select net.http_post(
    url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
           || '/functions/v1/queue-worker',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Authorization', 'Bearer ' ||
        (select decrypted_secret from vault.decrypted_secrets where name = 'service_role_key')
    ),
    body := '{}'::jsonb
  );
  $$
);
Enter fullscreen mode Exit fullscreen mode

Enqueueing from your app (without exposing the queue)

Supabase offers a toggle to expose queues to clients over PostgREST via the pgmq_public schema. If you use it, you must enable RLS on the underlying pgmq.q_* tables and grant per-function permissions per role; the tables ship with RLS off.

My take: for most apps, skip that entirely. Don't hand clients queue primitives; hand them one intent-shaped RPC that validates and enqueues on their behalf:

create or replace function public.enqueue_email(p_template text)
returns bigint
language plpgsql
security definer
set search_path = ''
as $$
declare
  v_msg_id bigint;
begin
  if auth.uid() is null then
    raise exception 'authentication required';
  end if;

  if p_template is null
     or p_template not in ('welcome', 'weekly_digest', 'export_ready') then
    raise exception 'unknown template';
  end if;

  select pgmq.send(
    queue_name => 'email_jobs',
    msg => jsonb_build_object(
      'template',        p_template,
      'user_id',         auth.uid(),
      'idempotency_key', format('email:%s:%s:%s', p_template, auth.uid(), to_char(now(), 'YYYY-MM-DD'))
    )
  ) into v_msg_id;

  return v_msg_id;
end;
$$;

grant execute on function public.enqueue_email(text) to authenticated;
Enter fullscreen mode Exit fullscreen mode

From a Next.js server action or route handler, enqueueing is one call:

const { data: msgId, error } = await supabase.rpc("enqueue_email", {
  p_template: "export_ready",
});
Enter fullscreen mode Exit fullscreen mode

The wrapper validates input, stamps the requester, and bakes in the idempotency key: three things a raw pgmq_public.send grant would leave to hope.

Housekeeping: the archive grows forever

One quiet cost of archiving everything: pgmq.a_email_jobs never shrinks on its own. Every completed and dead-lettered message accumulates there. It's useful history, until it's a multi-gigabyte table you're paying to store. Add a retention job:

select cron.schedule(
  'purge-email-jobs-archive',
  '0 3 * * *',
  $$ delete from pgmq.a_email_jobs where archived_at < now() - interval '30 days' $$
);
Enter fullscreen mode Exit fullscreen mode

Thirty days is enough runway to investigate any incident; adjust to taste.

When you should NOT use pgmq

Being honest about limits is the difference between an architecture and a hammer.

Skip pgmq and reach for SQS, Pub/Sub, or RabbitMQ when you need sustained throughput in the many-thousands-of-messages-per-second range (your queue would be competing with your app for the same Postgres resources), push-based delivery instead of polling, fan-out/routing topologies (pgmq is point-to-point: one queue, competing consumers), or org-level isolation between the queue and the primary database, so a queue stampede can't degrade user-facing queries.

For a typical SaaS or side project doing hundreds to a few thousand background jobs a day (emails, data refreshes, report generation, webhook processing), pgmq plus the patterns above is genuinely enough, and it's one less system to run.

FAQ

Does pgmq have a built-in dead-letter queue?
No. You implement one by checking each message's read_ct on read and moving messages that exceed your retry budget to a second queue. The read_with_dlq() function in this article is a complete implementation.

How do I limit retries in Supabase Queues?
There's no retry setting. Use read_ct, which increments on every read, as your attempt counter: divert any message whose read_ct exceeds your maximum to a dead-letter queue instead of handing it to the worker.

What happens if my worker crashes while processing a message?
Nothing immediately. When the message's visibility timeout expires, it becomes visible again, gets re-read by the next worker run, and its read_ct increments. Crashes and failures look identical to pgmq; both just mean the message wasn't archived.

Is pgmq exactly-once delivery?
Only within a single visibility window. Over a message's lifetime, treat pgmq as at-least-once: crashes, short timeouts, and DLQ reprocessing can all cause redelivery. Make handlers idempotent, especially around external side effects like email.

What visibility timeout should I use?
Your worst realistic (p99) processing time plus a buffer, remembering that for batch reads, the clock starts at read time, so the last message in a batch needs the timeout to cover everything ahead of it. For occasional long jobs, keep the timeout short and extend it per message with pgmq.set_vt() as a heartbeat.

What's the difference between pop() and read()?
pop() reads and deletes in one step; if your worker then crashes, the message is gone (at-most-once). read() leaves the message invisible until you explicitly archive or delete it, so failures retry (at-least-once). Use read() for anything you can't afford to lose.

Can browser clients send messages to a Supabase Queue directly?
Yes, Supabase can expose wrapper functions via the pgmq_public schema, but you must then enable RLS on the queue tables and manage per-role grants. A simpler, safer pattern for most apps is a single security definer RPC that validates input and enqueues on the client's behalf.

How do I monitor queue depth and failures?
select * from pgmq.metrics('queue_name') returns queue_length and oldest_msg_age_sec. Schedule a pg_cron job that checks your DLQ's depth and your main queue's oldest-message age, and fires a webhook via pg_net when either crosses a threshold.

What's the difference between Supabase Cron and Supabase Queues?
Cron (pg_cron) is a scheduler that runs something at a time. Queues (pgmq) is a message store that holds work until a consumer takes it. They compose: pg_cron ticks every minute to wake the worker; the queue holds the actual jobs, retry state, and backlog.

What do I need to run pgmq on Supabase?
Postgres 15.6.1.143 or later on the Supabase platform; enable it under Integrations → Queues or with create extension pgmq. Outside Supabase, pgmq installs as an extension or as plain SQL on stock Postgres.


Tested with pgmq 1.11.2 on PostgreSQL 16.14, July 2026. Every SQL block in this article was executed in order against a clean database: the happy path, the poison-message loop (read_ct reached 14), dead-lettering after exactly 5 attempts via read_with_dlq(), duplicate-claim rejection in claim_job(), lease contention in claim_worker_lease(), and DLQ reprocessing (the re-enqueued message came back with a fresh read_ct of 0). All output blocks are unedited captures from those runs. The Edge Function, pg_cron, pg_net, and Vault snippets follow Supabase's documented APIs; the database functions they call are the ones tested above.

Top comments (0)