Skip to main content

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.

manifest.json
{
"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):

FormatExampleMeaning
Raterate(30 minutes), rate(1 hour), rate(2 days)Fixed interval
5-field cron0 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.