Documentation
  1. 01Getting started
  2. 02Next.js and Vercel
  3. 03Servers and scripts
  4. 04Ruby on Rails
  5. 05Ruby
  6. More platforms

    1. 06SvelteKit
    2. 07Nuxt and Nitro
    3. 08React Router and Remix
    4. 09NestJS
    5. 10Strapi
    6. 11Netlify
    7. 12Firebase
    8. 13Convex
    9. 14Trigger.dev
    10. 15Inngest
    11. 16Cloudflare Workers
    12. 17Supabase and pg_cron
  7. 18Schedules, grace and timeouts
  8. 19What it catches
  9. 20Alerts
  10. 21Stores
  11. 22Dashboard and API
  12. 23MCP server
  13. 24Agent skill
  14. 25AI triage
  15. 26API reference
  16. 27Limits and design notes

Netlify

Netlify

A Netlify scheduled function runs on a cron schedule, in UTC, for at most 30 seconds. It cannot be called by URL in production, so there is no request to authenticate: wrap the body in run() rather than using handler(). The check is one more scheduled function, and the dashboard is an ordinary function with a path.

The client

Functions have no persistent disk, so use Postgres.

// lib/cronwatch.ts
import { cronwatch } from "@cronwatch/sdk";
import { postgres } from "@cronwatch/sdk/postgres";
import { slack } from "@cronwatch/sdk/slack";

export const cw = cronwatch({
  store: postgres({ connectionString: process.env.DATABASE_URL }),
  alerts: [slack({ webhookUrl: process.env.SLACK_WEBHOOK_URL! })],
  defaults: { timezone: "UTC" },      // Netlify runs schedules in UTC
});

export const digest = cw.job("daily-digest", { schedule: "0 6 * * *", grace: "10m", timeout: "2m" });

Set DATABASE_URL, SLACK_WEBHOOK_URL and CRONWATCH_TOKEN in the site’s environment variables with the Functions scope. Variables written in netlify.toml are not passed to functions.

A scheduled function

// netlify/functions/daily-digest.ts
import type { Config } from "@netlify/functions";
import { digest } from "../../lib/cronwatch";

export default async () => {
  await digest.run(async (job) => {
    const sent = await sendDigest();
    job.metric("emails", sent);
  });
};

export const config: Config = {
  schedule: "0 6 * * *",
};

The schedule can live in netlify.toml instead:

[functions."daily-digest"]
schedule = "0 6 * * *"

Netlify accepts five-field cron and the nicknames @hourly, @daily, @weekly, @monthly and @yearly, and so does a job’s schedule, so copy the same string into both.

The check

// netlify/functions/cronwatch-check.ts
import type { Config } from "@netlify/functions";
import { cw } from "../../lib/cronwatch";   // the module declares every job, so one that never ran is known

export default async () => {
  const result = await cw.check();
  console.log(`${result.jobs.length} jobs, ${result.alerts.length} alerts`);
};

export const config: Config = {
  schedule: "*/5 * * * *",
};

The check has the same 30 seconds. Sending alerts and retrying queued ones usually takes a second or two, but triage waits on a model for each alert, and one check spends up to 20 seconds retrying alerts no channel accepted. Leave triage off in the client this function uses, or run the check from somewhere without the limit (another host’s cron calling /cronwatch/api/check with CRON_SECRET as the bearer).

The dashboard

Netlify functions take a fetch Request and return a Response, so the routes need no adapter. A path pattern with /* does not match the bare path, so list both.

// netlify/functions/cronwatch.ts
import type { Config } from "@netlify/functions";
import { cw } from "../../lib/cronwatch";

const routes = cw.routes({ basePath: "/cronwatch" });

export default (request: Request) => routes.handler(request);

export const config: Config = {
  path: ["/cronwatch", "/cronwatch/*"],
};

Open /cronwatch?token=<CRONWATCH_TOKEN> once.

Limits that matter

A scheduled function stopped at 30 seconds cannot record its end. The run stays running until a check passes the job’s timeout, then it is marked stuck, so keep timeout short: a minute or two. Work that needs longer belongs in a background function (up to 15 minutes), which a scheduled function cannot be. Have the scheduled function call the background one’s URL with Authorization: Bearer <CRON_SECRET>, and wrap the work in the background function with handler(), which refuses a call without the secret. The job keeps its schedule, so a background run that never starts is still reported missed; give it a timeout a little over 15 minutes.

Scheduled functions run only on the published production deploy. Deploy previews and branch deploys do not run them on schedule (the Netlify UI can run one by hand), so the check runs from production alone.