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' $$
);
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;
Remove a job by name:
select cron.unschedule('purge-old-sessions');
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')
);
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
);
$$
);
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;
Two rules:
-
Keep keys in Vault, not in the job's SQL. Anything written inline into
cron.scheduleis stored in plain text incron.job. -
Check
net._http_responsefor failures. A401or500from 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);
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);
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
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
}
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
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
);
$$);
Then, in the Studio-style dashboard's SQL editor or from a test:
- Enqueue a few messages with
pgmq.send. - Wait a minute for the cron job to fire.
- Check
cron.job_run_detailsfor the run. - Check
net._http_responsefor the function's status code. - 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:orjsr: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_responsefor 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
recordwithold_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)