Supabase cron monitoring: catch the job that quietly stopped running
Last updated: July 2026 · By Yohann Kipfer, Cron-Ping
Supabase schedules jobs two ways — pg_cron (SQL, in the database) and Edge Functions triggered on a schedule. Neither tells you when it stops. A free project pauses after a week of inactivity, an Edge Function hits its monthly invocation quota, a migration drops the function a job calls — and the job just… doesn't run, with no email, no dashboard alarm. Turn the job into a dead man's switch by pinging out at the end of it:
-- at the end of the pg_cron job body
select net.http_get('https://cron-ping.com/p/TOKEN');If the job runs, the ping arrives; if it stopped, no ping arrives inside the grace period and you get an email or Slack message. pg_net sends the request asynchronously, so it never blocks or fails your job.
The two ways Supabase schedules work
Before you can monitor it, be clear which one you're running — they fail for different reasons.
| pg_cron | Scheduled Edge Function | |
|---|---|---|
| Where it lives | the Postgres database | Deno function + a pg_cron trigger |
| Defined with | cron.schedule('name', '*/5 * * * *', $$…$$) | cron.schedule calling net.http_post to the function URL |
| Runs | SQL inside the DB | your TypeScript, invoked over HTTP |
| Finest granularity | 1 minute (sub-minute in recent pg_cron) | 1 minute (bounded by pg_cron) |
| Run history | cron.job_run_details table | Edge Function logs + cron.job_run_details |
The Supabase dashboard's Cron and Integrations screens are a UI on top of pg_cron — under the hood everything is a row in the cron.job table. That matters: it means you can inspect and instrument every scheduled job with plain SQL.
Why Supabase cron breaks silently
- *A free project is paused. Supabase pauses projects on the Free plan after 7 days of inactivity*. A paused project's database is offline, so
pg_cronstops firing entirely — and it stays paused until you manually restore it. No job runs, no alert is sent. - *The Edge Function quota is exhausted.* The Free tier includes a capped number of Edge Function invocations per month. Once you're over, invocations are rejected — a scheduled function that runs every minute burns through a quota fast, and then silently stops.
- *A migration broke the job. `pg_cron` stores the job's SQL as text. If a later migration renames or drops a table or function the job references, every run now errors. `pg_cron` does not* disable the job — it keeps trying and failing every schedule, and the only trace is a
failedrow incron.job_run_details. Nobody is watching that table. - *Permissions / keys.* A scheduled Edge Function is usually invoked with a service-role key passed in the
cron.schedulebody. Rotate that key, tighten RLS, or change the function's auth, and the cron call starts returning 401 — the job 'ran', the HTTP call failed, nothing downstream happened.
/vercel-cron-monitor and /github-actions-monitoring: a managed scheduler has no concept of a missed run. It can tell you a run failed (sometimes, in a log you're not reading). It cannot tell you a run that should have happened never started. Only an external heartbeat — something waiting for a ping that doesn't come — closes that gap.Add the ping in a pg_cron job (pg_net)
pg_net lets Postgres make HTTP calls. Enable it once, then append a ping to the end of the job body so the request only goes out after the real work succeeds:
-- one-time
create extension if not exists pg_net;
-- schedule a nightly cleanup that reports it ran
select cron.schedule(
'nightly-cleanup',
'0 3 * * *',
$$
delete from events where created_at < now() - interval '30 days';
select net.http_get('https://cron-ping.com/p/TOKEN');
$$
);net.http_get is asynchronous — it queues the request and returns immediately, so the ping can never slow down or break your job. Because it's the last statement, it only reaches Cron-Ping if the delete above it didn't throw. If the delete errors, the transaction rolls back, no ping is sent, and the missed heartbeat alerts you.
To inspect a job's real history at any time:
select jobid, status, return_message, start_time, end_time
from cron.job_run_details
order by start_time desc
limit 10;Add the ping in a scheduled Edge Function
If the schedule triggers an Edge Function, ping from inside the function, just before it returns — so a ping means the function actually reached the end without throwing:
// supabase/functions/daily-report/index.ts
Deno.serve(async () => {
await buildAndSendReport(); // the real work
// heartbeat: only reached if the work above didn't throw
await fetch('https://cron-ping.com/p/TOKEN');
return new Response('ok');
});Wrap it in try/catch and hit the /fail endpoint in the catch, so an application error is distinguished from a scheduler that never fired:
Deno.serve(async () => {
try {
await buildAndSendReport();
await fetch('https://cron-ping.com/p/TOKEN'); // success
return new Response('ok');
} catch (e) {
await fetch('https://cron-ping.com/p/TOKEN/fail'); // ran, but failed
throw e;
}
});Measure duration and catch a zero-result run
The /start and /fail endpoints turn the heartbeat into something richer than up/down. Ping /start at the top of the job and /success at the end, and Cron-Ping records the *duration* — a nightly aggregation that normally takes 40 seconds and suddenly takes 6 minutes is a signal a table grew unexpectedly or a query lost its index.
You can also make the ping conditional on the result. If a job is supposed to process rows and processed zero, that's often a failure disguised as a success — send /fail instead:
do $$
declare n int;
begin
delete from expired_sessions where expires_at < now();
get diagnostics n = row_count;
if n = 0 then
perform net.http_get('https://cron-ping.com/p/TOKEN/fail');
else
perform net.http_get('https://cron-ping.com/p/TOKEN');
end if;
end $$;FAQ
Does Supabase alert me when a cron job stops?
No. Supabase surfaces a run history in cron.job_run_details and Edge Function logs, but it sends no alert when a job fails or, worse, stops running entirely (a paused free project, an exhausted quota). You have to either poll that table yourself or push a heartbeat out to an external monitor.
What happens to my cron jobs when a free Supabase project is paused?
They stop completely. Free projects pause after 7 days of inactivity; the database goes offline, so pg_cron can't fire and any scheduled Edge Function trigger has nothing to run against. The project stays paused until you restore it manually. An external heartbeat catches this within its grace period — the internal scheduler can't warn you about its own database being down.
Will pg_net slow down or break my cron job?
No. net.http_get / net.http_post are asynchronous: they enqueue the request and return immediately, and the actual HTTP call happens in the pg_net background worker. If the ping endpoint is slow or unreachable, your SQL job has already committed. Put the ping as the last statement so it only enqueues after the real work succeeds.
Does pg_cron disable a job after it errors?
No — by default it keeps running the job on schedule and records each failure in cron.job_run_details with a status of 'failed'. So a job broken by a migration fails silently every night. That's exactly the case a missed-heartbeat alert catches, because a broken job's ping never leaves the transaction.
Can I monitor a scheduled Edge Function without touching SQL?
Yes — add a single await fetch('https://cron-ping.com/p/TOKEN') before the function returns. That's enough for a heartbeat. Use pg_net only if you schedule pure-SQL jobs with pg_cron, or if you want the ping to be conditional on what the SQL actually did.
Turn your Supabase cron into a dead man's switch
One net.http_get at the end of a pg_cron job, or one fetch before an Edge Function returns. If the job stops — paused project, blown quota, a migration that broke it — the ping never arrives and Cron-Ping emails or Slacks you.
Free plan: 10 checks, email alerts, 7-day history. No card. EU-hosted.