Scheduled Functions
A function with type: "scheduled" runs automatically on a cron schedule โ once per installation. If ten organizations install your app, your function runs ten times per tick, each run isolated to one organization's data, options and permissions.
{
"functions": [
{
"name": "sync-open-requests",
"type": "scheduled",
"handler": "./functions/sync-open-requests/dist/handler.js",
"schedule": "rate(30 minutes)",
"schedule_timezone": "Europe/Berlin"
}
]
}
Schedule expressionsโ
Two formats, both validated at deploy time (and locally by epilot app validate):
| Format | Example | Meaning |
|---|---|---|
| Rate | rate(30 minutes), rate(1 hour), rate(2 days) | Fixed interval |
| 5-field cron | 0 3 * * * | crontab.guru-compatible; evaluated in schedule_timezone (default Europe/Berlin) |
Rules:
- Minimum interval: 15 minutes โ denser expressions are rejected, including list tricks like
0,5 * * * *. - At most 5 scheduled functions per app.
- Don't restrict day-of-month and day-of-week in the same expression โ set one of them to
*.
Execution semanticsโ
- Jitter: runs execute within roughly 15 minutes after the scheduled time, not at the exact second. Design for "around 3 AM", not "at 03:00:00".
- Hard 60-second budget per run.
- No overlap: if the previous run is still going, the tick is skipped.
- At-least-once: a failed delivery is retried; occasionally a tick may run twice โ make your logic idempotent.
- Lifecycle: schedules are created when an org installs your app, updated when the installation moves to a new version, and deleted on uninstall. A disabled installation (missing required options) is skipped.
Do bounded work per tickโ
60 seconds is a budget, not a target. The reliable pattern is incremental processing: handle a bounded batch, let the next tick pick up the rest.
const MAX_ITEMS_PER_RUN = 150;
const TIME_BUDGET_MS = 45_000;
async function handler(input, context) {
const started = Date.now();
const items = await findPendingItems(context, input); // bounded query
let processed = 0;
for (const item of items.slice(0, MAX_ITEMS_PER_RUN)) {
if (Date.now() - started > TIME_BUDGET_MS) break; // next tick continues
await processItem(context, input, item); // idempotent!
processed++;
}
return { success: true, processed, remaining: items.length - processed };
}
Two more habits that keep scheduled syncs well-behaved:
- Write only on change. Diff before you PATCH an entity โ otherwise every tick touches every entity and fires the org's entity-based automations for nothing.
- Let single items fail individually. Log per-item errors and continue; only return
{ error_reason }when the whole run is broken (e.g. the target system is down), so failures in your Insights mean something.
Observabilityโ
Every run is recorded in your app's Insights (source APP_FUNCTION) โ successes, skips and failures with their messages, per version and per installing organization. Installing orgs see the schedule (expression, timezone, description) in the read-only Functions tab of the installed app.