Scheduled Tasks with Supabase
How to add cron jobs, background tasks, and scheduled work to Supabase Edge Functions.
Prerequisites
- A Supabase project with Edge Functions enabled
- The Supabase CLI installed
- A Runlater account (free tier works)
SDK overview
The runlater-js
SDK methods used in this guide:
| Method | Description |
|---|---|
| rl.send(url, opts) | Queue a task for immediate execution |
| rl.delay(url, opts) |
Execute after a delay (e.g. "10m", "1d")
|
| rl.cron(name, opts) | Create or update a recurring cron job (idempotent) |
| rl.sync(opts) | Declare all tasks at once — creates, updates, and removes to match |
Why you need this
Supabase Edge Functions run on Deno Deploy — they're serverless and stateless. There's no built-in way
to call an Edge Function on a schedule. You can
build it yourself: use pg_cron
to populate queue tables, set up database webhooks to trigger Edge Functions, add deduplication logic,
error handling, and retry mechanisms. It works, but it's a lot of moving parts for what should be a
simple task.
Runlater replaces all of that with a single SDK call. You define scheduled tasks via the SDK, and
Runlater calls your Edge Functions on schedule with the right auth headers. No queue tables, no
database webhooks, no deduplication logic — just
rl.cron()
and you're done.
Setup
The SDK is used in two contexts: setup scripts (Node.js, run locally) and Edge Functions (Deno, run on Supabase). Install differs slightly for each:
npm install runlater-js
// No install needed — use the npm: specifier in your import:
import Runlater from "npm:runlater-js@0.2.0"
Store your API keys. You'll need your Runlater key and your Supabase
service_role
key (so Runlater can
authenticate with your Edge Functions):
# .env (for local setup scripts)
RUNLATER_KEY=pk_live_your_api_key
SUPABASE_URL=https://xyzproject.supabase.co
SUPABASE_ANON_KEY=your_supabase_anon_key
anon
key (not service_role) in task headers. Inside
your Edge Function, use
SUPABASE_SERVICE_ROLE_KEY
from Deno.env
for admin operations.
Example: Daily report email
Query yesterday's data from your Supabase database and send a summary email every morning at 8 AM. First, create the cron task from a local setup script:
import Runlater from "runlater-js" const rl = new Runlater(process.env.RUNLATER_KEY!) await rl.cron("daily-report", { url: `${process.env.SUPABASE_URL}/functions/v1/daily-report`, schedule: "0 8 * * *", headers: { "Authorization": `Bearer ${process.env.SUPABASE_ANON_KEY}`, }, })
Then create the Edge Function:
import { createClient } from "@supabase/supabase-js" Deno.serve(async () => { // Use service_role inside the function for admin access const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ) // Get yesterday's signups and orders const yesterday = new Date(Date.now() - 86400000).toISOString() const { count: newUsers } = await supabase .from("profiles") .select("*", { count: "exact", head: true }) .gte("created_at", yesterday) const { count: orders } = await supabase .from("orders") .select("*", { count: "exact", head: true }) .gte("created_at", yesterday) // Send via your email provider await fetch("https://api.resend.com/emails", { method: "POST", headers: { "Authorization": `Bearer ${Deno.env.get("RESEND_KEY")}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "reports@myapp.com", to: "team@myapp.com", subject: `Daily report: ${newUsers} signups, ${orders} orders`, html: `<p>${newUsers} new users, ${orders} new orders in the last 24h.</p>`, }), }) return new Response(JSON.stringify({ newUsers, orders }), { headers: { "Content-Type": "application/json" }, }) })
Example: Process file uploads
When a user uploads a file to Supabase Storage, queue a background task to process it. You can trigger this from a database webhook that fires when a row is inserted into your storage metadata table, calling an Edge Function that queues the work via Runlater:
import Runlater from "npm:runlater-js" Deno.serve(async (req) => { const { record } = await req.json() // From database webhook const rl = new Runlater(Deno.env.get("RUNLATER_KEY")!) // Queue thumbnail generation await rl.send( `${Deno.env.get("SUPABASE_URL")}/functions/v1/generate-thumbnail`, { body: { file_path: record.name, bucket: "uploads" }, headers: { "Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`, }, retries: 3, timeout: 60000, } ) return new Response("OK") })
Example: Onboarding drip emails
When a user signs up, schedule a series of onboarding emails over the next week using rl.delay():
import Runlater from "npm:runlater-js" Deno.serve(async (req) => { const { record: user } = await req.json() const rl = new Runlater(Deno.env.get("RUNLATER_KEY")!) const sendEmailUrl = `${Deno.env.get("SUPABASE_URL")}/functions/v1/send-email` const headers = { "Authorization": `Bearer ${Deno.env.get("SUPABASE_ANON_KEY")}`, } // Welcome email — 10 minutes after signup await rl.delay(sendEmailUrl, { delay: "10m", body: { to: user.email, template: "welcome" }, headers, }) // Tips email — 1 day later await rl.delay(sendEmailUrl, { delay: "1d", body: { to: user.email, template: "tips" }, headers, }) // Check-in email — 3 days later await rl.delay(sendEmailUrl, { delay: "3d", body: { to: user.email, template: "checkin" }, headers, }) return new Response("OK") })
send-email
Edge Function before sending.
Example: Clean up expired sessions
Run a nightly cleanup to delete expired rows from your database:
await rl.cron("session-cleanup", { url: `${process.env.SUPABASE_URL}/functions/v1/cleanup-sessions`, schedule: "0 3 * * *", headers: { "Authorization": `Bearer ${process.env.SUPABASE_ANON_KEY}`, }, })
import { createClient } from "@supabase/supabase-js" Deno.serve(async () => { const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ) const { data } = await supabase .from("sessions") .delete() .lt("expires_at", new Date().toISOString()) .select("id") return new Response(JSON.stringify({ deleted: data?.length ?? 0 }), { headers: { "Content-Type": "application/json" }, }) })
Advanced: Declarative task management
Use rl.sync() to define all your
scheduled tasks in one place. Run this on deploy:
import Runlater from "runlater-js" const rl = new Runlater(process.env.RUNLATER_KEY!) const BASE = `${process.env.SUPABASE_URL}/functions/v1` const auth = { "Authorization": `Bearer ${process.env.SUPABASE_ANON_KEY}` } await rl.sync({ tasks: [ { name: "daily-report", url: `${BASE}/daily-report`, schedule: "0 8 * * *", headers: auth, }, { name: "session-cleanup", url: `${BASE}/cleanup-sessions`, schedule: "0 3 * * *", headers: auth, }, { name: "usage-stats", url: `${BASE}/usage-stats`, schedule: "0 * * * *", headers: auth, }, ], deleteRemoved: true, })
Best practices
-
Use service_role for background work.
Edge Functions called by Runlater don't have a
user session. Use the
service_rolekey inside the function (fromDeno.env) to bypass Row Level Security for admin operations like cleanups and reports. -
Pass anon key in task headers.
When creating the task, include your Supabase
anonkey in theheadersfield for authentication. Keep theservice_rolekey only inside your Edge Functions, never in external task headers. - Return 2xx for success, 4xx/5xx for failure. Runlater uses the HTTP status code to determine if the task succeeded. A non-2xx response triggers a retry (up to the configured limit). Retries use exponential backoff.
- Keep handlers idempotent. Retries mean your function may be called more than once. Use upserts and check for existing records before creating new ones.
- Set up notifications. Configure email or Slack alerts in your organization settings so you know immediately when a task fails.