SvelteKit’s Cloudflare adapter (@sveltejs/adapter-cloudflare) does not natively support the scheduled event handler. This article documents a workaround to add cron job support to any SvelteKit app deployed on Cloudflare Workers.
Background
Cloudflare Workers support a scheduled event handler for cron triggers. SvelteKit’s @sveltejs/adapter-cloudflare generates _worker.js with only a fetch handler — there’s no built-in way to export a scheduled handler.
This was raised in sveltejs/kit#4841. Rich Harris stated that native support for scheduled events is outside the adapter’s scope, and the issue was closed with no plans to add it. The community established two workarounds:
- Post-build file append (documented here) — append a
scheduledhandler to the generated worker file after build. Simple, single worker deployment. - Service binding — create a separate Cloudflare Worker for cron that calls the SvelteKit app’s endpoints via service bindings. More infrastructure, but cleaner separation.
This article covers approach #1, based on this comment.
The Solution
Append a scheduled handler to the generated _worker.js after the SvelteKit build. The generated file exports worker_default as a plain object with a fetch method. We attach a scheduled method to it post-build.
Setup
1. Create cron/job.js
The scheduled handler. Receives event, env (all Cloudflare bindings), and ctx (execution context).
// cron/job.js
/**
* @param {import("@cloudflare/workers-types").ScheduledEvent} event
* @param {Env} env
* @param {import('@cloudflare/workers-types').EventContext<Env, "", {}>} ctx
*/
worker_default.scheduled = async (event, env, ctx) => {
console.log('[CRON] Running at:', new Date().toISOString());
// Example: query D1 database
// const result = await env.DB.prepare('SELECT COUNT(*) as total FROM users').first();
// console.log('Users:', result?.total);
// Example: write to R2
// await env.BUCKET.put('last-cron.txt', new Date().toISOString());
// Example: call external API
// const res = await fetch('https://api.example.com/webhook', { method: 'POST' });
}; 2. Create cron/append.js
Post-build script that appends job.js to the generated worker.
// cron/append.js
import { appendFile, readFile } from 'fs/promises';
const file = await readFile('cron/job.js', 'utf8');
await appendFile('.svelte-kit/cloudflare/_worker.js', file, 'utf8'); 3. Update package.json
Chain the append after the Vite build:
{
"scripts": {
"build": "vite build && node cron/append.js",
"preview": "pnpm run build && wrangler dev"
}
} 4. Update wrangler.jsonc
Add the triggers block:
{
"triggers": {
"crons": ["0 1 * * *"]
}
} That’s it — 2 new files, 2 config changes.
How It Works
vite buildgenerates.svelte-kit/cloudflare/_worker.jswith aworker_defaultobject containing afetchmethodnode cron/append.jsappendscron/job.jsto the end of that file, addingworker_default.scheduled = async (...) => { ... }- Cloudflare Workers runtime sees both
fetchandscheduledon the exported default object - On the configured cron schedule, Cloudflare calls
worker_default.scheduled()directly — not via HTTP - The handler receives
envwith all bindings (D1, R2, KV, etc.) — same bindings available to the fetch handler
Testing Locally
1. Build and start the worker with cron testing enabled
pnpm build && npx wrangler dev --test-scheduled The --test-scheduled flag exposes the /__scheduled endpoint for testing cron triggers locally.
2. Trigger the cron manually
In a separate terminal:
curl "http://localhost:8787/__scheduled?cron=*+*+*+*+*" The cron query parameter must be URL-encoded (spaces as +).
3. Check the output
Logs appear in the terminal where wrangler is running:
[CRON] Running at: 2026-03-08T10:25:28.453Z
[wrangler:info] GET /__scheduled 200 OK (19ms) Troubleshooting: /__scheduled returns 307 or 404
This means SvelteKit’s routing is intercepting the /__scheduled request (e.g. auth middleware redirecting to login). The cron logic still executes — check the logs above the HTTP status line.
This only affects local testing. In production, Cloudflare calls scheduled() directly, not via HTTP.
Constraints
Hard constraints (inherent to the append approach):
worker_defaultis the variable name generated by SvelteKit’s Cloudflare adapter. If the adapter changes this name in a future version, the append will break. Check the end of.svelte-kit/cloudflare/_worker.jsafter a build to verify.- Cannot use SvelteKit path aliases (
$lib/,$app/,$env/, etc.) — these are resolved by Vite at build time, and the appended code runs outside Vite’s module system.
Soft constraints (implementation choices, can be worked around):
- Plain JS instead of TypeScript — could be solved by adding an esbuild step:
esbuild cron/job.ts --outfile=cron/job.js --format=esm && node cron/append.js - No ORM (Drizzle, etc.) — could be solved by bundling with esbuild. Raw SQL via
env.DB.prepare()is simpler for cron logic and avoids the extra build complexity.
Cron Syntax Reference
Format: minute hour day-of-month month day-of-week
| Pattern | Description |
|---|---|
* * * * * | Every minute (testing only) |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour |
0 1 * * * | Daily at 1:00 AM UTC |
0 */6 * * * | Every 6 hours |
0 0 * * 1 | Every Monday at midnight UTC |
0 0 1 * * | First day of every month |
Accessing Cloudflare Bindings
The env parameter provides access to all bindings configured in wrangler.jsonc:
worker_default.scheduled = async (event, env, ctx) => {
// D1 Database
const rows = await env.DB.prepare('SELECT * FROM users WHERE active = ?').bind(1).all();
await env.DB.prepare('INSERT INTO logs (message) VALUES (?)').bind('cron ran').run();
// Batch multiple D1 statements
await env.DB.batch([
env.DB.prepare('UPDATE table1 SET col = ?').bind('val1'),
env.DB.prepare('INSERT INTO table2 (col) VALUES (?)').bind('val2'),
]);
// R2 Object Storage
await env.BUCKET.put('report.json', JSON.stringify({ generated: new Date() }));
const obj = await env.BUCKET.get('config.json');
// KV Namespace
await env.KV.put('last-run', new Date().toISOString());
const lastRun = await env.KV.get('last-run');
// Environment Variables
const apiKey = env.API_KEY;
// External HTTP calls
await fetch('https://api.example.com/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'cron completed' }),
});
}; Multiple Cron Schedules
You can define multiple schedules. All of them invoke the same scheduled handler — use event.cron to differentiate:
// wrangler.jsonc
{
"triggers": {
"crons": ["0 * * * *", "0 0 * * *"]
}
} // cron/job.js
worker_default.scheduled = async (event, env, ctx) => {
if (event.cron === '0 * * * *') {
// Hourly task
}
if (event.cron === '0 0 * * *') {
// Daily task
}
}; Alternative: Route Through SvelteKit
Instead of doing work directly in job.js, you can route the cron into a SvelteKit API endpoint:
// cron/job.js
worker_default.scheduled = async (event, env, ctx) => {
const req = new Request('https://example.com/_cron', { method: 'GET' });
await worker_default.fetch(req, env, ctx);
}; This calls worker_default.fetch with a fake request, which SvelteKit routes to src/routes/_cron/+server.ts. The origin (example.com) doesn’t matter — it’s never actually fetched.
Pros: Access to TypeScript, SvelteKit imports, Drizzle ORM, full framework features. Cons: Must bypass auth middleware for the /_cron path. Slightly more overhead.
Production Deployment
No special steps. Deploy as normal — the build script handles everything:
pnpm deploy
# or: wrangler deploy Verify cron triggers in the Cloudflare dashboard: Workers & Pages > your-worker > Settings > Triggers > Cron Triggers.