Scheduled Tasks with Supabase

How to add cron jobs, background tasks, and scheduled work to Supabase Edge Functions.

Prerequisites

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:

For setup scripts (Node.js)
npm install runlater-js
For Edge Functions (Deno)
// 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
Security note: The Supabase key you put in task headers is stored on Runlater's servers and sent with every request. Use the 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:

scripts/setup-tasks.ts
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:

supabase/functions/daily-report/index.ts
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:

supabase/functions/on-upload/index.ts
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():

supabase/functions/on-signup/index.ts
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")
})
Cancellation: If a user deletes their account, the scheduled drip emails are still pending. Use the Runlater dashboard or the API to cancel pending tasks for that user, or check if the user still exists inside your send-email Edge Function before sending.

Example: Clean up expired sessions

Run a nightly cleanup to delete expired rows from your database:

scripts/setup-tasks.ts
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}`,
  },
})
supabase/functions/cleanup-sessions/index.ts
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:

scripts/sync-tasks.ts
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_role key inside the function (from Deno.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 anon key in the headers field for authentication. Keep the service_role key 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.
Ready to get started? Create a free account and schedule your first task in under 5 minutes. See the getting started guide for a full walkthrough.

Back to all guides