Background work in the application falls into three categories distinguished by trigger mechanism, not code structure. The execution, logging, and monitoring are identical — what varies is how and why a job starts.
Terminology
| Term | Definition | Input | Example |
|---|---|---|---|
| Scheduled job | Runs on a fixed cadence | None | session-cleanup, log-cleanup |
| Reactive job | Triggered by a system event | Event payload | Welcome email on signup, graph indexing on upload |
| Manual job | Triggered by an admin | Optional parameters | Re-index documents, force token refresh |
"Reactive" over "event-driven" — less overloaded (avoids confusion with DOM events, event sourcing, message queues).
All three are "jobs." The trigger varies. The job does not.
Architecture Diagram
┌─────────────────────────────┐
│ JOB REGISTRY │
│ $lib/server/jobs/index.ts │
│ │
│ slug, label, schedule, │
│ trigger type, execute fn │
└──────────────┬───────────────┘
│
┌─────────────────────────┼──────────────────────────┐
│ │ │
┌─────────▼──────────┐ ┌─────────▼──────────┐ ┌─────────▼──────────┐
│ SCHEDULED │ │ REACTIVE │ │ MANUAL │
│ │ │ │ │ │
│ Platform adapter: │ │ Inngest serve: │ │ Admin form action: │
│ • Vercel Cron │ │ /api/inngest │ │ /app/admin/jobs │
│ • setInterval │ │ │ │ │
│ • External HTTP │ │ inngest.send() │ │ requireAdmin() │
│ │ │ → step.run() │ │ → runJob() │
└─────────┬───────────┘ └─────────┬───────────┘ └─────────┬───────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ runJob() / logJobExecution() │
│ Execution wrapper + job_execution table │
└──────────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ DOMAIN MODULES │
│ $lib/server/[domain]/ │
│ (same functions serve all trigger types) │
└─────────────────────────────────────────────────────────────────────────┘
This extends the multi-client core pattern from multi-client-core.md. Reactive jobs via Inngest become the 5th adapter type alongside form actions, REST API, AI tools, and scheduled jobs.
Vendor-Agnostic Scheduling
Principle: the job registry owns the schedule. Platform config just reads it.
Schedules are defined in TypeScript alongside the job they describe, not in platform-specific config files. This means switching hosting platforms requires zero job code changes.
How It Works
Job Registry (source of truth)
schedule: '0 3 * * *'
│
├── Vercel adapter: vercel.json crons (derived or manually synced)
├── Persistent adapter: croner library parses schedule, fires at correct time
└── External adapter: any HTTP cron service calls /api/cron/[job]
The Three Triggering Strategies
Strategy A: Vercel Cron (serverless platforms)
Vercel sends an HTTP GET to /api/cron/[job] on the schedule defined in vercel.json. The endpoint validates the bearer token, looks up the job, and calls runJob().
vercel.json must be kept in sync with the registry. The registry is authoritative — if they disagree, the registry is correct.
{
"crons": [
{ "path": "/api/cron/session-cleanup", "schedule": "0 3 * * *" },
{ "path": "/api/cron/log-cleanup", "schedule": "0 4 * * 0" }
]
}
Strategy B: Persistent scheduler (containers, VPS, Fly, Railway)
A cron parser reads the schedule field from the registry and fires jobs at the correct times. No vercel.json needed. No HTTP round-trip — jobs execute in-process.
// scheduler.ts — persistent platforms only
import { Cron } from 'croner';
for (const [slug, job] of Object.entries(jobs)) {
if (job.schedule) {
new Cron(job.schedule, () => runJob(slug, 'scheduler'));
}
}
This replaces the current flat-interval setInterval approach with actual cron expression parsing. The platform detection (platform.persistent) gates activation — on Vercel, this code never runs.
Strategy C: External HTTP cron (any platform)
The /api/cron/[job] endpoint is already platform-agnostic. Any HTTP client that passes the bearer token can trigger any registered job. This works with external cron services (cron-job.org, EasyCron, GitHub Actions scheduled workflows) as a universal fallback.
Migration Between Platforms
| From → To | What Changes | What Stays |
|---|---|---|
| Vercel → Fly/Railway | Delete vercel.json crons. Scheduler activates automatically. |
Job registry, runner, domain functions |
| Vercel → Cloudflare | Replace vercel.json with Cloudflare cron triggers. Or use external HTTP cron. |
Job registry, runner, domain functions |
| Container → Vercel | Add vercel.json crons. Scheduler auto-disables (platform.persistent === false). |
Job registry, runner, domain functions |
| Any → External cron | Point the service at /api/cron/[job] with bearer token. |
Everything |
Module Structure
src/lib/server/
jobs/ ← Scheduled + Manual jobs
index.ts ← Registry with enriched metadata
runner.ts ← runJob(slug, trigger) — execute + log
log.ts ← logJobExecution() — shared logging utility
scheduler.ts ← Cron-aware scheduler for persistent platforms
delivery-scheduler.ts ← Fast-interval notification delivery
session-cleanup.ts
log-cleanup.ts
notification-cleanup.ts
notification-delivery.ts
telegram-token-cleanup.ts
discord-token-refresh.ts
inngest/ ← Reactive jobs (separate, NOT inside jobs/)
client.ts ← Inngest client instance
functions/ ← One file per reactive job
index.ts ← Barrel export
index.ts ← Exports client + function list
src/routes/
api/
cron/[job]/+server.ts ← Vercel cron + external HTTP trigger
inngest/+server.ts ← Inngest serve endpoint
app/
admin/jobs/ ← Admin UI for job management
+page.server.ts ← List + trigger (form actions)
+page.svelte
[slug]/
+page.server.ts ← Per-job execution history
+page.svelte
Why Inngest Lives Outside jobs/
Inngest functions have a fundamentally different contract:
| Scheduled/Manual | Reactive (Inngest) | |
|---|---|---|
| Contract | () => Promise<number> |
({ event, step }) => Promise<void> |
| Execution | Synchronous invocation | HTTP callback-based durable execution |
| Retries | Handled by runner | Per-step, managed by Inngest |
| Input | None or optional params | Typed event payload |
| Registration | jobs registry |
Inngest serve() function array |
Forcing both into one registry would require a painful union type that benefits nobody. They share the job_execution table for unified monitoring — that is the right seam.
Job Registry
The registry is the single source of truth for all scheduled and manual jobs.
// src/lib/server/jobs/index.ts
export interface Job {
/** The work to perform. Returns a result count. */
execute: (payload?: unknown) => Promise<number>;
/** Human-readable name for the admin UI */
label: string;
/** How this job is typically triggered */
trigger: 'scheduled' | 'manual';
/** One-sentence description */
description: string;
/** Cron expression (scheduled jobs only). UTC timezone. */
schedule?: string;
}
export const jobs: Record<string, Job> = {
'session-cleanup': {
execute: sessionCleanup,
label: 'Session Cleanup',
trigger: 'scheduled',
description: 'Remove expired auth sessions.',
schedule: '0 3 * * *',
},
'log-cleanup': {
execute: logCleanup,
label: 'Log Cleanup',
trigger: 'scheduled',
description: 'Delete job execution logs older than retention period.',
schedule: '0 4 * * 0',
},
// ...
};
The schedule field uses standard cron syntax (minute, hour, day-of-month, month, day-of-week). All schedules are UTC. The same expression is used by the persistent scheduler (via cron parser) and must match vercel.json on Vercel.
Execution and Observability
The Runner
runJob(slug, trigger) wraps any job execution with timing, error capture, and logging. It serves scheduled, manual, and (optionally) Inngest jobs.
The runner writes to jobs.job_execution — an immutable event log:
jobs.job_execution
├── job_slug text ← registry slug or Inngest function ID
├── status enum ← 'success' | 'failure'
├── trigger enum ← 'cron' | 'scheduler' | 'manual' | 'inngest'
├── started_at timestamptz
├── finished_at timestamptz
├── duration_ms integer
├── result_count integer? ← job-specific metric (rows deleted, emails sent)
└── error_message text? ← sanitized error on failure
Shared Logging for Inngest
Inngest functions don't go through runJob() (different execution model), but they write to the same job_execution table via an extracted logJobExecution() utility:
// src/lib/server/jobs/log.ts
export async function logJobExecution(params: {
slug: string;
trigger: TriggerType;
startedAt: Date;
finishedAt: Date;
durationMs: number;
resultCount: number | null;
status: 'success' | 'failure';
errorMessage: string | null;
}): Promise<void> {
db.insert(jobExecution)
.values(params)
.catch((err) => console.error(`[job-log] Failed to log ${params.slug}:`, err));
}
This gives the admin UI a single view of all background work — scheduled, reactive, and manual — regardless of trigger mechanism.
Reactive Jobs (Inngest)
Why Inngest
For the Vercel + SvelteKit stack, Inngest is the recommended choice for reactive jobs:
- Official SvelteKit serve handler (
inngest/sveltekit) - Step-based durable execution — each
step.run()is a separate Vercel function invocation, sidestepping timeout limits - Built-in retries per step (not per function)
- Dashboard with run history, step traces, and event payloads
- Free tier: 100K function runs/month
How It Works
User signs up
→ route handler calls inngest.send({ name: 'user/signup', data: { userId } })
→ Inngest cloud receives event
→ Inngest calls back POST /api/inngest with the function to execute
→ step.run('send-welcome', () => sendWelcomeEmail(...))
→ step completes, result persisted by Inngest
→ step.run('create-defaults', () => createDefaultUserData(...))
→ each step is a separate Vercel function invocation
Integration Pattern
// src/routes/api/inngest/+server.ts
import { serve } from 'inngest/sveltekit';
import { inngest } from '$lib/server/inngest/client';
import { functions } from '$lib/server/inngest/functions';
const handler = serve({ client: inngest, functions });
export const GET = handler.GET;
export const POST = handler.POST;
export const PUT = handler.PUT;
Inngest functions follow the multi-client core pattern — thin adapters calling domain functions:
// src/lib/server/inngest/functions/user-welcome.ts
import { inngest } from '../client';
import { NotificationService } from '$lib/server/notifications';
export const userWelcome = inngest.createFunction(
{ id: 'user-welcome', retries: 3 },
{ event: 'user/signup' },
async ({ event, step }) => {
await step.run('send-welcome', async () => {
await NotificationService.send({
userId: event.data.userId,
actorId: 'system:welcome',
type: 'system',
title: 'Welcome!',
});
});
}
);
Domain modules never import from $lib/server/inngest/. Events flow outward from adapter boundaries.
Hooks Integration
The Inngest endpoint needs two accommodations in hooks.server.ts:
- CSRF exclusion — Inngest callbacks carry
x-inngest-signature, notx-requested-with. Add/api/inngest/to the CSRF path exclusion list. - Session populate — Inngest callbacks have no session cookie.
sessionPopulatesetslocals.user = null, which is correct. No change needed.
Inngest's serve() handler verifies the signing key internally — no manual signature verification required.
Manual Jobs (Admin UI)
Manual jobs use the same runJob() path as scheduled jobs. The difference is the trigger source: an authenticated admin action instead of a cron schedule.
Route Pattern
Use SvelteKit form actions at /app/admin/jobs/, not REST API endpoints. The /app/* route guard handles authentication. The action adds an explicit requireAdmin() check.
// src/routes/app/admin/jobs/+page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
requireAdmin(locals);
// List jobs from registry + recent executions from DB
};
export const actions: Actions = {
trigger: async ({ request, locals }) => {
requireAdmin(locals);
const slug = (await request.formData()).get('slug') as string;
const result = await runJob(slug, 'manual');
return { result };
},
};
For jobs that exceed the Vercel function timeout (>300s on Pro), the admin action enqueues to Inngest instead of running synchronously.
Platform Constraints
Vercel (Serverless)
| Constraint | Value | Impact |
|---|---|---|
| Max function duration | 300s (Pro), 800s (Pro + Fluid Compute) | Jobs must complete within this window |
| Cron minimum frequency | 1/minute (Pro), 1/day (Hobby) | Use Inngest for sub-minute reactive work |
| Cron delivery | At-least-once | Jobs must be idempotent |
| Cron retries | None | Runner logs failures; external alerting required |
| Cron environment | Production only | No cron in preview deployments |
| Cron redirects | Not followed | Route path in vercel.json must match exactly |
Persistent Platforms (Container, Fly, Railway)
| Constraint | Value | Impact |
|---|---|---|
| Process lifetime | Indefinite | Scheduler runs in-process, no HTTP overhead |
| Cold start | One-time | Scheduler starts once, survives requests |
| Compute suspension | Platform-specific (Neon suspends after 5min idle) | Handle Connection terminated unexpectedly |
Neon PostgreSQL
- Use direct connection (port 5432) for any job that uses advisory locks or LISTEN/NOTIFY
- Pooled connection (port 6432, PgBouncer transaction mode) breaks session-level features silently
- Compute suspension after 5 minutes of inactivity kills existing TCP connections
Security
Authentication Per Trigger Type
| Trigger | Auth Mechanism | Guard |
|---|---|---|
| Vercel Cron | CRON_SECRET bearer token (timing-safe) |
In cron endpoint |
| External HTTP cron | Same bearer token | In cron endpoint |
| Persistent scheduler | None — in-process, trusted | Platform detection gates activation |
| Inngest callback | Inngest signing key (verified by SDK) | In serve() handler |
| Admin manual | Session cookie + requireAdmin() |
In form action |
Hardening Checklist
- Idempotency: All scheduled jobs must produce the same outcome whether run once or twice. Use upserts or check-before-act patterns.
- Concurrency control: If a job takes longer than its interval, use PostgreSQL advisory locks to prevent parallel execution.
- Error sanitization: Strip database URLs, tokens, and API keys from error messages before storing in
job_execution. - Rate limiting: Cron endpoints should be rate-limited (5/hour per job slug). Admin trigger actions should be rate-limited (3/minute per admin).
- Audit logging: Manual job triggers should log the admin user ID alongside the execution record.
- Response redaction: The cron endpoint should return only status and timing, not error messages.
waitUntil for Fire-and-Forget Side Effects
For non-critical work that should not block the response (analytics, cache invalidation), use waitUntil from @vercel/functions. This is NOT a job system — it has no retries, no logging, and silently drops failures.
import { platform } from '$lib/server/platform';
// Only available on Vercel — conditional import
if (platform.id === 'vercel') {
const { waitUntil } = await import('@vercel/functions');
waitUntil(
invalidateCache(key).catch(err => console.error('[cache]', err))
);
}
Rules:
- Always
.catch()inside the promise — unhandled rejections are silently lost - Never use for work where failure matters (emails, data mutations, billing)
- Guard the import with platform detection —
@vercel/functionsonly exists on Vercel
Database Schema Changes
The job_trigger enum needs an additional value for Inngest:
-- Add 'inngest' to the job_trigger enum
ALTER TYPE jobs.job_trigger ADD VALUE 'inngest';
The TriggerType in runner.ts extends accordingly:
export type TriggerType = 'cron' | 'scheduler' | 'manual' | 'inngest';
Technology Decision Record
Chosen
| Tool | Role | Why |
|---|---|---|
| Vercel Cron | Scheduled job trigger (serverless) | Zero cost, zero infra, already working |
| croner | Schedule parsing (persistent platforms) | ~3KB, zero deps, replaces flat setInterval |
| Inngest | Reactive job orchestration | Official SvelteKit support, step-based durable execution, free tier |
runJob() + form actions |
Manual job trigger | Existing pattern, admin UI with progressive enhancement |
Rejected
| Tool | Why Not |
|---|---|
| BullMQ | Requires persistent Redis connection. Incompatible with serverless. |
| pg-boss | Neon PgBouncer breaks LISTEN/NOTIFY and advisory locks silently. Worker is just cron polling (up to 60s delay). More operational work than Inngest for same result. |
| graphile-worker | Memory leaks reported with Bun. Not officially supported on Bun. |
| Trigger.dev v3 | CLI requires Node.js (friction in Bun-first project). Experimental Bun runtime has broken OpenTelemetry. Better suited for compute-intensive work if needed later. |
Known Tradeoffs
- Inngest is a managed service dependency. If Inngest has an outage, reactive jobs pause. Acceptable for non-critical workflows. October 2025 incident (multi-day partial outage) is documented precedent.
- Two registries (jobs/ and inngest/) means two places to check. Mitigated by the unified
job_executiontable and the admin UI reading from both. vercel.jsonmust be synced manually with registry schedules. A build-time validation script can catch drift.
References
| Source | Relevance |
|---|---|
| Vercel Cron Jobs | Cron configuration, at-least-once delivery, production-only |
Vercel waitUntil |
Fire-and-forget semantics, timeout behavior |
| Inngest SvelteKit | Official serve handler, function registration |
| Inngest Durable Execution | Step memoization, HTTP re-invocation model |
| Inngest October 2025 Incident | Production reliability precedent |
| pg-boss Serverless Pattern | Maintainer-recommended supervise: false for serverless |
SvelteKit init Hook Gotcha |
Runs on every cold start on Vercel, not truly once |
| multi-client-core.md | The adapter/domain pattern this document extends |