Netlify Scheduled Functions for a Recurring Model Job
9 min read · updated August 11, 2026
A Netlify scheduled function runs on a cron expression in UTC and gets thirty seconds to finish — half what the same code would get behind an HTTP path. That number decides the architecture: a scheduled function that calls a model directly is a design that works in testing and fails on the first long input.
Declaring the schedule
Netlify documents two ways. In TypeScript or JavaScript, a schedule property on the exported config:
// netlify/functions/nightly-digest.mts
import type { Config } from "@netlify/functions";
export default async (req: Request) => {
const { next_run } = await req.json();
console.log("scheduled run; next invocation at", next_run);
};
export const config: Config = {
schedule: "@hourly",
};Or, for any language, a block in netlify.toml keyed on the function name:
[functions."nightly-digest"] schedule = "0 6 * * *"
Netlify documents standard cron syntax with the timezone always UTC, plus the extension shorthands @yearly, @monthly, @weekly, @daily and @hourly. The request body is JSON carrying a next_run field with the timestamp of the next scheduled invocation — useful for logging, and for deciding how far back a reconciliation query should look.
Netlify’s functions API reference lists schedule and path as mutually exclusive. A function is either scheduled or routed, never both, so a “run it now” HTTP endpoint has to be a second function that shares the same module.
Thirty seconds, not sixty
Netlify’s functions configuration page documents a 30-second execution limit for scheduled functions, against 60 seconds for synchronous ones and 15 minutes for background functions, and states that these limits are not configurable.
Thirty seconds is enough for a short completion on a fast model with a small output. It is not enough for a long document, a reasoning model, a retry, or several items in a loop — and the failure mode is exactly the one described on the timeout page: the clock is wall-clock time, so a function awaiting a provider is spending its budget while doing nothing. Worse, a scheduled function has no user watching, so the failure is discovered when someone notices the digest stopped arriving.
Dispatcher plus worker
The pattern that survives is to make the scheduled function do no model work at all. It decides what needs doing and hands each unit to a background function, which has fifteen minutes. The scheduled function then only needs to be fast enough to enumerate work and fire requests — comfortably inside thirty seconds for any sane queue depth.
// netlify/functions/nightly-digest.mts
import type { Config } from "@netlify/functions";
export default async (req: Request) => {
const { next_run } = await req.json();
const pending = await findDocumentsNeedingSummary();
// Netlify documents URL as the site's primary address. Set SITE_BASE_URL
// yourself if you would rather not depend on it being present at runtime.
const base = process.env.SITE_BASE_URL ?? process.env.URL;
await Promise.all(
pending.map((doc) =>
fetch(base + "/api/summarise", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jobId: doc.id, documentKey: doc.key }),
}),
),
);
console.log("dispatched", pending.length, "next run", next_run);
};
export const config: Config = { schedule: "0 6 * * *" };Each of those fetch calls returns as soon as the background function is accepted — Netlify documents an immediate 202 — so the dispatcher’s runtime is dominated by network round trips rather than by model latency. Note that the body carries a key rather than the document: background functions are limited to a 256 KB request and response payload, far below the 6 MB allowed for a synchronous one. The worker side of this, including where results go and how a caller learns a job finished, is built out on the background functions page.
If the queue is large enough that even dispatching takes thirty seconds, dispatch a bounded batch per run and let the next run continue — which works precisely because the query asks what still needs doing rather than what changed in the last hour.
Choosing the expression
The expression looks like the least consequential decision on this page and is not, because a scheduled model job competes for the same provider capacity as everybody else’s.
- Avoid the top of the hour and midnight UTC.
@hourlyand@dailyresolve to minute zero, and they are what everyone reaches for first. If your job dispatches fifty model calls, it is dispatching them into the busiest moment of the provider’s minute.7 * * * *costs nothing and moves you out of the crowd; the same applies within your own account, where two jobs on@hourlywill contend with each other for the same rate limit. - Match the interval to the work, not to the schedule you wish you had. An expression that fires more often than the job takes to run is how overlap starts. With a reconciliation query of the kind above, a longer interval loses nothing — the next run picks up whatever the last one did not reach.
- UTC has no daylight saving, and your users do. A digest fixed at
0 6 * * *lands at 07:00 in Amsterdam in winter and 08:00 in summer without the expression changing. If the local hour matters, either accept the drift explicitly or schedule more frequently and let the job decide whether it is the right local time to act. - Watch the day-of-week field. Netlify documents standard cron syntax, in which Sunday is
0. Writing* * * * 1for “the first day” produces a job that runs every minute on Mondays, which is both wrong and expensive if each run touches a model.
Testing it
- Invoke it locally with the Netlify CLI:
netlify functions:invoke --name nightly-digest. This runs the handler without waiting for the schedule, which is the only practical way to iterate on it. - Deploy to production. Netlify documents that scheduled functions only execute on published deploys — not on Deploy Previews and not on branch deploys. A schedule that appears to do nothing on a preview is behaving correctly.
- Use the Run now control in the Netlify UI to trigger a published deploy’s scheduled function once, and read the function log. This is the console-dependent step on an otherwise CLI-driven page, and it is the part most likely to have moved by the time you read this.
- Verify the schedule fired unattended by logging a timestamp on entry and checking the next day. A cron that is syntactically valid and semantically wrong — the classic being a day-of-month field where you meant day-of-week — produces silence, not an error.
What will catch you out
- UTC, always. A “6am digest” scheduled as
0 6 * * *arrives at 7am or 8am local time in most of Europe depending on the season, and moves twice a year without the expression changing. Decide in UTC and document it. - No retry to rely on. Assume at-least-once at best. Key the work on a stable id and make a repeat run a no-op, for the same reason as on the Vercel cron page — a duplicated model call is a duplicated bill.
- Errors are silent. Nobody receives the 500. Log a terminal outcome on every path, including the empty-queue path, so that “no log line” unambiguously means “did not run”.
- The site URL is not a constant. Reading
process.env.URLrather than hard-coding the deploy URL keeps the dispatcher working when the site is renamed or a custom domain moves.