How to Add Scheduled Tasks to an AI-Built App

How to wire recurring scheduled tasks into an AI-built app with pg_cron and Supabase, and the missed-run, timezone, and duplicate-run bugs that break them in production.

Steve Jefferson
Steve Jefferson
Developer Advocate
28 August 20261 min read

If a task in your app needs to happen on a schedule, every day at 9am, every Monday, every hour, on the hour, you need a scheduled task (a cron job), not a background job. This guide walks through wiring a recurring task into a typical AI-app-builder plus Postgres/Supabase stack, using pg_cron to fire an edge function on a fixed schedule, and then covers the three failure modes that actually break scheduled tasks in production: missed runs after downtime, timezone drift, and duplicate runs from overlapping schedules.

Scheduled tasks vs background jobs: not the same thing

It is worth being precise here because the two get confused constantly. A background job is triggered by an event: a user uploads a file, and a job processes it asynchronously so the request does not hang. It runs once, tied to that one event. If you have not read it yet,

How to Add Background Jobs to an AI-Built App covers that pattern in depth, queues, retries, processing a single upload.

A scheduled task is different. It runs on a clock, regardless of whether anything happened. "Send a digest email every day at 9am" runs whether or not a user did anything that day. "Charge recurring invoices on the 1st of the month" runs whether or not anyone logged in. The trigger is time, not an event. That distinction matters because the failure modes are completely different: background jobs fail when the triggering event fires twice or gets dropped, scheduled tasks fail when the clock itself misbehaves, the server was down when the task should have fired, the schedule assumed one timezone but users live in ten, or two overlapping schedules both fire at once.

The stack: pg_cron plus an edge function

Most AI app builders sit on top of Postgres, and Supabase ships pg_cron as an extension you can enable with one click. The pattern is simple: pg_cron fires on a schedule, and it either runs SQL directly or calls an edge function over HTTP using pg_net. For anything beyond a trivial SQL update, calling an edge function is the better call, it gives you real code, proper error handling, and access to third-party APIs like an email provider.

Here is a worked example: a daily digest email sent to every user at 9am in their own timezone.

Step 1: enable pg_cron and pg_net

-- run once, in the Supabase SQL editor
create extension if not exists pg_cron;
create extension if not exists pg_net;

Step 2: schedule the trigger

Schedule a job that runs every hour, on the hour, UTC. It does not try to guess every user's local 9am in one shot, it runs hourly and asks the database which users are due right now. That single design choice is what solves the timezone problem, more on that below.

select cron.schedule(
  'daily-digest-hourly-tick',
  '0 * * * *',  -- every hour at minute 0, UTC
  $$
  select net.http_post(
    url := 'https://your-project.supabase.co/functions/v1/send-daily-digest',
    headers := jsonb_build_object(
      'Content-Type', 'application/json',
      'Authorization', 'Bearer ' || current_setting('app.service_role_key')
    ),
    body := jsonb_build_object('trigger_time', now())
  );
  $$
);

Step 3: the edge function does the real work

The edge function receives the tick, figures out which users are due for their digest right now based on their stored timezone and preferred send hour, and sends only those. This is pseudocode, but it maps directly onto a Deno edge function on Supabase or an equivalent on any serverless runtime.

// send-daily-digest edge function (pseudocode)
export async function handler(req) {
  const { trigger_time } = await req.json();
  const runId = crypto.randomUUID();

  // idempotency: claim this run before doing any work
  const claimed = await db.query(
    `insert into job_runs (job_name, run_key, started_at)
     values ('daily-digest', $1, now())
     on conflict (job_name, run_key) do nothing
     returning id`,
    [hourBucket(trigger_time)] // e.g. '2026-08-28T09:00'
  );
  if (claimed.rows.length === 0) {
    return json({ skipped: 'already ran for this hour bucket' });
  }

  // catch-up aware: find every user due since their last successful send,
  // not just users due in this exact hour
  const dueUsers = await db.query(
    `select id, timezone, preferred_hour, last_digest_sent_at
     from users
     where digest_enabled = true
       and (
         last_digest_sent_at is null
         or last_digest_sent_at < (now() - interval '20 hours')
       )`
  );

  for (const user of dueUsers.rows) {
    const localHour = convertToLocalHour(trigger_time, user.timezone);
    if (localHour !== user.preferred_hour) continue;
    await sendDigestEmail(user);
    await db.query(
      `update users set last_digest_sent_at = now() where id = $1`,
      [user.id]
    );
  }

  await db.query(`update job_runs set finished_at = now() where id = $1`, [claimed.rows[0].id]);
  return json({ sent: dueUsers.rows.length });
}

Two things carry the actual weight in that function: the job_runs table with a unique constraint that makes each hour bucket claimable only once, and the query that looks back twenty hours instead of trusting that this exact hour is the only chance a user gets. Both exist specifically to survive the failure modes below.

The second worked example: recurring invoice reminders

The same skeleton applies to billing. Say you want to remind customers three days before an invoice is due. Instead of scheduling a job per invoice (which gets messy fast, you would be creating and cancelling cron entries constantly), schedule one recurring job that runs once a day and queries for what is due.

select cron.schedule(
  'invoice-reminder-daily',
  '0 13 * * *',  -- 13:00 UTC daily
  $$
  select net.http_post(
    url := 'https://your-project.supabase.co/functions/v1/send-invoice-reminders',
    headers := jsonb_build_object('Content-Type', 'application/json')
  );
  $$
);

-- inside the edge function, the actual selection query:
select id, customer_id, due_date
from invoices
where status = 'unpaid'
  and due_date - current_date = 3
  and reminder_sent_at is null;

Notice the query condition is a state check (unpaid, reminder not yet sent), not a time-window check. That is the pattern to copy for any recurring task: the schedule decides when to look, the query decides what is actually due. If the schedule slips by a few minutes, or the whole job gets skipped for a day, the query still finds the right invoices next time it runs.

Failure mode 1: missed runs after downtime

Cron schedulers assume the system is always up. If your database or the edge function runtime goes down for two hours and your digest job was supposed to fire at 9am during that window, pg_cron does not queue up a delayed run once things recover, that tick is simply gone. If your task's logic only looks at "is it currently the target hour", the daily digest for anyone in that timezone never sends that day.

The fix is catch-up logic: instead of asking "is now the right time," ask "has enough time passed since I last did this." That is exactly what the last_digest_sent_at < now() - interval '20 hours' check does above. It gives the job a window to catch up in, so a missed 9am tick still gets picked up by the 10am or 11am tick once the system is back. For anything genuinely time-critical (a payment that must post on a specific date), add explicit reconciliation: a separate daily check that flags anything overdue by more than one scheduled cycle so a human gets alerted instead of the gap going silent.

Failure mode 2: timezone drift

This is the one that generates support tickets months after launch. It is tempting to hardcode a schedule like '0 9 * * *' and call it done, but that is 9am UTC, not 9am for a user in Chicago or Mumbai. The fix used in both examples above is to never let the cron schedule encode a user-facing time at all. Cron only controls how often you check. The actual "is it 9am for this specific user" decision happens inside the query, against a timezone column you store per user.

Store timezone as an IANA name ("America/Chicago"), not a raw UTC offset, because offsets change with daylight saving and a fixed offset silently drifts by an hour twice a year. Convert at read time using your database's timezone functions or your runtime's date library, never bake a specific offset into the schedule itself.

Failure mode 3: duplicate runs from overlapping schedules

Two ways this happens in practice. First, pg_cron can fire the next scheduled tick before the previous run has finished, if your edge function is slow (a big batch send taking longer than an hour) you can end up with two invocations processing the same batch of users simultaneously. Second, a manual retrigger, a deploy that re-registers the cron job, or a webhook retry from your HTTP layer can call the same function twice for what should be one logical run.

The fix is the job_runs table with a unique constraint shown in the pseudocode above. Before doing any real work, the function tries to insert a claim row keyed on the job name and a deterministic run identifier (the hour bucket, the invoice reminder date, whatever uniquely identifies "this logical run"). If that insert conflicts, another invocation already claimed it, and the function exits immediately. This is the same idempotency-key pattern used for payment APIs, applied to scheduling instead of requests.

A checklist before you ship a scheduled task

  • The cron expression controls how often you check, never a user-facing local time directly.

  • Store user timezones as IANA names, and convert to local time inside the query or function, not in the schedule.

  • Every scheduled task claims a unique run key before doing work, so overlapping or duplicate invocations exit early.

  • The selection query looks back over a window (hours or a full day), not just the exact current moment, so a missed tick gets caught on the next one.

  • Long-running tasks either finish well within the schedule interval or explicitly lock against overlap, not both assumed for free.

  • There is a way to see the last successful run per job (a job_runs table, a status page, or both) so a silent failure doesn't go unnoticed for a week.

Monitoring and iterating

A scheduled task that fails silently is worse than one that never existed, because everyone assumes it is working. Once you have the job_runs table in place, surfacing its last-success timestamp on an internal status page or piping failed runs into an audit log turns a silent gap into something you actually see. If timezone handling across your app is new territory, How to Handle Time Zones in an AI-Built App goes deeper on storing and converting timezones correctly outside the scheduling context specifically.

Start with one recurring task, wire in the claim table and the lookback window from day one even if it feels like overkill for a single digest email, and the pattern scales cleanly to every other scheduled task you add later. For the broader picture of where scheduled tasks fit

among the other pieces of a production app, How to Build an App With AI: A Complete Guide covers the full path from prompt to shipped product.

How did this land?

About the author

Steve Jefferson
Steve Jefferson

Developer Advocate

Steve builds something with Swarmz every week and writes up what worked, what broke, and what he'd do differently. Tutorials and hands-on guides are his lane.

Share

Get the next post in your inbox

One email a month. Product updates, engineering posts, and the best of Built with Swarmz.

I agree to receive emails about AI building tips and Swarmz product news. Unsubscribe any time.