DEV Community

Hugo Rus
Hugo Rus

Posted on

Supabase Background Jobs, Tested Locally: Cron, Queues, HTTP from SQL, and Webhooks

Every Supabase app eventually grows a background layer. A nightly cleanup job. A digest email every morning. A queue that processes uploads one at a time. A webhook that fires when an order is paid.

In Supabase, that layer is mostly SQL: pg_cron schedules jobs, pgmq queues work, pg_net makes HTTP calls from the database (usually to an Edge Function), and database webhooks fire on inserts and updates.

It's also the part of the app most teams never run locally. It's easy to push a cron job straight to the hosted project and find out the next morning that the schedule was in the wrong timezone, or that the Edge Function it calls rejected the request.

This post walks through the four building blocks with working SQL, the patterns that hold up in production, and how to exercise all of it on your laptop before it ships.

The four building blocks

Tool Job Typical use
pg_cron Run SQL on a schedule Nightly cleanup, scheduled digests
pg_net Make HTTP requests from SQL Call an Edge Function from a cron job or trigger
pgmq Durable message queue in Postgres Process work one item at a time, with retries
Database webhooks HTTP call on insert, update, or delete React to a row changing

Most real setups combine them: a cron job uses pg_net to call an Edge Function, which reads work from a pgmq queue.

1. Scheduled jobs with pg_cron

select cron.schedule(
  'purge-old-sessions',          -- job name
  '0 3 * * *',                   -- every day at 03:00
  $$ delete from public.sessions where created_at < now() - interval '30 days' $$
);
Enter fullscreen mode Exit fullscreen mode

Inspect jobs and their runs:

select jobid, jobname, schedule, active from cron.job;

select jobid, status, return_message, start_time
from cron.job_run_details
order by start_time desc
limit 20;
Enter fullscreen mode Exit fullscreen mode

Remove a job by name:

select cron.unschedule('purge-old-sessions');
Enter fullscreen mode Exit fullscreen mode

The timezone trap. Hosted Supabase runs cron schedules in UTC. 0 3 * * * is 3am UTC, which might be the middle of your users' afternoon. Write the timezone in the job name or a comment, and check it before you ship.

2. Calling an Edge Function from SQL with pg_net

Most cron jobs shouldn't do the work in SQL. They should kick off an Edge Function that can call APIs, send email, and use secrets.

select net.http_post(
  url := 'https://<project-ref>.supabase.co/functions/v1/send-digest',
  headers := jsonb_build_object(
    'Content-Type', 'application/json',
    'Authorization', 'Bearer ' || (
      select decrypted_secret from vault.decrypted_secrets where name = 'digest_function_key'
    )
  ),
  body := jsonb_build_object('kind', 'daily')
);
Enter fullscreen mode Exit fullscreen mode

Combine it with cron:

select cron.schedule(
  'daily-digest',
  '0 8 * * *',   -- 08:00 UTC
  $$
    select net.http_post(
      url := 'https://<project-ref>.supabase.co/functions/v1/send-digest',
      headers := jsonb_build_object(
        'Content-Type', 'application/json',
        'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'digest_function_key')
      ),
      body := '{"kind":"daily"}'::jsonb
    );
  $$
);
Enter fullscreen mode Exit fullscreen mode

pg_net is asynchronous: net.http_post queues the request and returns a request ID immediately. Check what came back in the response table:

select id, status_code, content, created
from net._http_response
order by created desc
limit 10;
Enter fullscreen mode Exit fullscreen mode

Two rules:

  • Keep keys in Vault, not in the job's SQL. Anything written inline into cron.schedule is stored in plain text in cron.job.
  • Check net._http_response for failures. A 401 or 500 from your function won't surface anywhere else.

3. Queues with pgmq

When work needs to happen reliably, one item at a time, with retries if something fails, use a queue instead of a bare cron job.

select pgmq.create('image_jobs');

-- Producer: enqueue work (from a trigger, an RPC, or app code)
select pgmq.send('image_jobs', '{"image_id": "abc123"}'::jsonb);
Enter fullscreen mode Exit fullscreen mode

A worker (usually an Edge Function called on a schedule) reads a batch:

-- Read up to 10 messages, hiding them from other readers for 60 seconds
select msg_id, read_ct, message
from pgmq.read('image_jobs', 60, 10);
Enter fullscreen mode Exit fullscreen mode

When a message is processed, delete or archive it:

select pgmq.delete('image_jobs', 42);     -- done, discard
select pgmq.archive('image_jobs', 43);    -- done, keep a record
Enter fullscreen mode Exit fullscreen mode

If the worker crashes before deleting a message, the visibility timeout (60 seconds above) expires and the message becomes readable again. That's your retry. Use read_ct to spot messages that keep failing and move them aside after a few attempts, so one bad message doesn't block the queue forever.

4. Database webhooks

Webhooks call a URL when rows change. The payload has a fixed shape your Edge Function can rely on:

{
  "type": "INSERT",
  "table": "orders",
  "schema": "public",
  "record": { "id": "o1", "status": "paid", "user_id": "u1" },
  "old_record": null
}
Enter fullscreen mode Exit fullscreen mode

For UPDATE, both record and old_record are set. For DELETE, record is null.

Make handlers idempotent. Deliveries can be retried, and an UPDATE can fire even when the column you care about didn't change. Compare record and old_record before sending a receipt, and record what you've already processed.

Testing all of it locally

The usual options for running this before deploying:

  • The Supabase CLI (supabase start) runs the full Docker stack, including these extensions. It's the highest-fidelity local option, at about a dozen containers and roughly 1.4 GB of RAM.
  • tinbase, an open-source, Supabase-compatible backend that runs as one process with no Docker. It emulates pg_cron, pgmq, pg_net, Vault, and database webhooks, so the SQL above runs as written.

A local test loop with tinbase:

npx tinbase start
Enter fullscreen mode Exit fullscreen mode

Put your jobs in a migration so every environment gets the same schedule:

-- supabase/migrations/20261012000000_background_jobs.sql
select pgmq.create('image_jobs');

select cron.schedule('process-images', '* * * * *', $$
  select net.http_post(
    url := 'http://127.0.0.1:54321/functions/v1/process-images',
    headers := jsonb_build_object('Content-Type', 'application/json'),
    body := '{}'::jsonb
  );
$$);
Enter fullscreen mode Exit fullscreen mode

Then, in the Studio-style dashboard's SQL editor or from a test:

  1. Enqueue a few messages with pgmq.send.
  2. Wait a minute for the cron job to fire.
  3. Check cron.job_run_details for the run.
  4. Check net._http_response for the function's status code.
  5. Check that the queue is empty (or the bad message is still there with a higher read_ct).

Edge Functions written with Deno.serve run in-process from supabase/functions/, so the cron → pg_net → function → queue loop happens entirely on your laptop.

What to know about the emulation:

  • It implements the surface real projects call, not every function each extension offers. If you use something exotic, check tinbase's compatibility table.
  • It's alpha, and built for local development and CI, not production.
  • Edge Functions with npm: or jsr: imports need bundling first.
  • Run your jobs once against the real Supabase CLI or a staging project before deploying, especially anything time-sensitive.

Checklist before shipping background jobs

  • [ ] Jobs are defined in migrations, not typed into the dashboard
  • [ ] Every schedule's timezone is written down (hosted cron runs in UTC)
  • [ ] Function keys live in Vault, never inline in cron.schedule
  • [ ] Someone (or something) checks net._http_response for errors
  • [ ] Queue workers delete or archive every processed message
  • [ ] Messages that fail repeatedly are moved aside using read_ct
  • [ ] Webhook handlers are idempotent and compare record with old_record
  • [ ] The whole loop has run at least once before it reaches production

Background jobs fail quietly, at 3am, in UTC. Running them on your laptop first is the cheapest insurance you can buy.

What's the background job that bit you hardest? I'd like to collect the war stories.

Top comments (0)