Background Tasks & Cron
ExisJS provides an integrated task scheduling system for background jobs, recurring intervals, and delayed timeouts. It eliminates common Node.js scheduling hazards including timer drift, execution overlap, unhandled exceptions crashing the process, timezone/DST inaccuracies, and lingering zombie timers during graceful shutdowns.
Both Functional (cron()) and Class-Based OOP (@Cron()) paradigms are fully supported with complete feature parity.
File Conventions & Auto-Discovery
ExisJS automatically discovers, compiles, and mounts scheduled cron tasks during server bootstrap using two flexible layout conventions:
- Single-File / Root HTTP Convention (
src/http/cron.tsorsrc/http/cron.js): Define and manage all your application's background jobs in a single root file atsrc/http/cron.ts. You can also co-locate cron files within route slices (e.g.,src/http/admin/cron.tsorsrc/http/billing/cron.ts). Multiple jobs can be exported using named exports, default exports, or an array of job definitions (export default [job1, job2]). - Dedicated Task Directory (
src/cron/*): Organize individual tasks into separate files insidesrc/cron/(e.g.,src/cron/cleanup.ts,src/cron/sync.ts). Every file insidesrc/cron/is automatically registered upon boot.
1. Functional Paradigm (cron)
Import cron and CronExpression from exisjs/cron to define scheduled background tasks.
Root Single-File Management (src/http/cron.ts)
You can define and export all your application's background tasks directly within src/http/cron.ts:
import { cron, CronExpression } from 'exisjs/cron'
// Task 1: Nightly synchronization at midnight UTCexport const nightlySync = cron({ name: 'nightly-sync', schedule: CronExpression.EVERY_DAY_AT_MIDNIGHT, timezone: 'UTC', preventOverlap: true, retries: 2, retryDelayMs: 3000, async run({ app, log }) { log.info('Running nightly data synchronization...') }, onError(err, { log }) { log.error({ err }, 'Nightly sync failed after retry attempts') }})
// Task 2: Human-readable duration scheduleexport const queuePoller = cron.every('5 minutes', async ({ log }) => { log.info('Polling external queue...')}, { name: 'queue-poller', preventOverlap: true})
// Task 3: Fixed millisecond intervalexport const heartbeat = cron.interval(30_000, async ({ log }) => { log.info('System heartbeat active')}, { name: 'health-heartbeat'})
// Task 4: One-time startup delayed task (runs 5s after server boots)export const cacheWarmup = cron.timeout(5_000, async ({ log }) => { log.info('Pre-warming cache in memory...')}, { name: 'cache-warmup'})Exporting an Array of Jobs (export default [...])
In both src/http/cron.ts and src/cron/*.ts, you can also export an array of jobs as the default export:
import { cron, CronExpression } from 'exisjs/cron'
export default [ cron({ name: 'session-cleanup', schedule: CronExpression.EVERY_HOUR, async run({ log }) { log.info('Purging expired sessions...') } }), cron.interval(60_000, async ({ log }) => { log.info('Checking worker status...') }, { name: 'worker-status' })]Dedicated File Convention (src/cron/cleanup.ts)
import { cron, CronExpression } from 'exisjs/cron'
export default cron({ name: 'db-cleanup', schedule: CronExpression.EVERY_DAY_AT_2AM, timezone: 'America/New_York', preventOverlap: true, async run({ log }) { log.info('Purging stale database records...') }})2. Class-Based (OOP) Paradigm (@Cron)
In class-based applications, schedule methods on @Injectable() service classes using decorators from exisjs/decorators with full Dependency Injection support.
import { Injectable, Cron, Interval, TimeoutTask, CronExpression } from 'exisjs/decorators'import { DatabaseService } from '../database/database.service'import { LoggerService } from '../common/logger.service'
@Injectable()export default class MaintenanceService { constructor( @Inject(DatabaseService) private readonly db: DatabaseService, @Inject(LoggerService) private readonly logger: LoggerService ) {}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { name: 'nightly-purge', timezone: 'UTC', preventOverlap: true, retries: 2, retryDelayMs: 5000, }) async purgeExpiredSessions() { this.logger.info('Purging expired user sessions...') await this.db.deleteExpiredSessions() }
@Interval(60_000, { name: 'db-health-check', preventOverlap: true, }) async pingDatabase() { await this.db.ping() }
@TimeoutTask(5_000, { name: 'cache-init', }) async warmupCache() { this.logger.info('Pre-warming cache after server boot...') }}Configuration Options (CronJobOptions)
The following options configure job execution, error recovery, and scheduling policies:
| Option | Type | Default | Description |
|---|---|---|---|
name | string | Auto-generated | Unique identifier for the job, used in logs and programmatic management. |
schedule | string | — | Standard 5-part or 6-part cron pattern (e.g., '0 0 * * *'). |
interval | number | — | Execution interval in milliseconds (used with interval jobs). |
timeout | number | — | Startup delay in milliseconds before executing once (used with timeout jobs). |
timezone | string | System Local | IANA timezone identifier (e.g., 'UTC', 'America/New_York', 'Asia/Kolkata'). |
preventOverlap | boolean | true | When true, skips execution if the previous tick is still running. |
autoStart | boolean | true | Automatically starts the scheduler when the job is registered. |
retries | number | 0 | Number of retry attempts if the handler throws an error. |
retryDelayMs | number | 1000 | Milliseconds to wait between retry attempts. |
run / handle | (ctx) => Promise<any> | — | The execution handler function receiving CronContext. |
onError | (err, ctx) => void | — | Optional callback triggered when all execution attempts fail. |
Execution Context (CronContext)
Every job handler receives a CronContext object containing execution metadata and framework instances:
export interface CronContext { /** The Exis application instance */ app: App<any> /** Structured logger instance */ log: Logger /** Name of the running job */ jobName: string /** Timestamp when this execution iteration started */ startedAt: Date /** Current execution iteration count */ iteration: number /** Signal aborted when graceful server shutdown is initiated */ signal?: AbortSignal}Standard Cron Expression Constants (CronExpression)
ExisJS exports pre-tested cron expressions for common schedules:
import { CronExpression } from 'exisjs/cron'
CronExpression.EVERY_SECOND // '* * * * * *'CronExpression.EVERY_5_SECONDS // '*/5 * * * * *'CronExpression.EVERY_10_SECONDS // '*/10 * * * * *'CronExpression.EVERY_30_SECONDS // '*/30 * * * * *'CronExpression.EVERY_MINUTE // '* * * * *'CronExpression.EVERY_5_MINUTES // '*/5 * * * *'CronExpression.EVERY_10_MINUTES // '*/10 * * * *'CronExpression.EVERY_30_MINUTES // '*/30 * * * *'CronExpression.EVERY_HOUR // '0 * * * *'CronExpression.EVERY_2_HOURS // '0 */2 * * *'CronExpression.EVERY_6_HOURS // '0 */6 * * *'CronExpression.EVERY_DAY_AT_MIDNIGHT // '0 0 * * *'CronExpression.EVERY_DAY_AT_1AM // '0 1 * * *'CronExpression.EVERY_DAY_AT_2AM // '0 2 * * *'CronExpression.EVERY_DAY_AT_NOON // '0 12 * * *'CronExpression.EVERY_WEEK // '0 0 * * 0'CronExpression.EVERY_WEEKDAY // '0 0 * * 1-5'CronExpression.EVERY_WEEKEND // '0 0 * * 6,0'CronExpression.EVERY_MONTH // '0 0 1 * *'CronExpression.EVERY_YEAR // '0 0 1 1 *'Core Reliability Guarantees
1. Overlap Prevention (preventOverlap: true)
If a job takes longer than its scheduling frequency (for example, a database migration running for 90 seconds on a 60-second interval), standard Node.js timers cause overlapping concurrent runs. ExisJS locks execution during active runs and skips overlapping ticks until the current run completes.
preventOverlap works inside one process. When the app runs on several instances, every instance runs every job; set app.cron.lock so only the instance holding the lock runs each tick:
import { randomUUID } from 'node:crypto'const instanceId = randomUUID()
export default exis({ async onStart(app) { app.cron.lock = { acquire: async (job, ttlMs) => (await redis.set(`cron:${job}`, instanceId, 'PX', ttlMs, 'NX')) === 'OK', release: async (job) => { if ((await redis.get(`cron:${job}`)) === instanceId) await redis.del(`cron:${job}`) }, } },})The lock is held for at most lockTtlMs (default: the interval, or 5 minutes for cron patterns). A Postgres advisory lock (pg_try_advisory_lock) works the same way. app.cron.trigger(name) runs a job without the lock.
2. Isolated Error Sandbox
Exceptions thrown inside cron handlers are caught within an isolated execution boundary. An uncaught error in a background task will never crash the HTTP server or prevent subsequent scheduled runs. Errors are logged with execution duration and forwarded to your onError handler.
3. Accurate Timezone & DST Calculation
Cron schedules evaluate IANA timezones (e.g. 'America/Chicago', 'Europe/Berlin', 'UTC') using native Intl.DateTimeFormat evaluations, adjusting accurately for Daylight Saving Time (DST) transitions.
4. Quiet Under Tests
Under NODE_ENV=test jobs are registered but not started; see Testing. Each job file is loaded once: from src/ during development and tests, and from the compiled .exis/server/src/ in production.
5. Graceful Shutdown & Drain Window
When the server receives SIGTERM or SIGINT:
- The scheduler halts all future timer registrations.
- Active timers are unreferenced (
timer.unref()). - Running tasks are allowed to complete within the configurable drain window (default: 5000ms) before the process terminates.
Programmatic Management (app.cron)
Manage registered jobs dynamically at runtime from HTTP endpoints or administrative services via app.cron:
import { controller, route } from 'exisjs/router'
export default controller({ // Get execution statistics and status for all jobs listStats: route.get('/', async ({ app }) => { return { jobs: app.cron.getStats() } }),
// Manually trigger an immediate execution triggerJob: route.post('/:name/trigger', async ({ app, params }) => { const result = await app.cron.trigger(params.name) return { success: true, result } }),
// Pause a running job pauseJob: route.post('/:name/pause', async ({ app, params }) => { const job = app.cron.getJob(params.name) if (!job) return { success: false, error: 'Job not found' } job.pause() return { success: true, message: `Job ${params.name} paused` } }),
// Resume a paused job resumeJob: route.post('/:name/resume', async ({ app, params }) => { const job = app.cron.getJob(params.name) if (!job) return { success: false, error: 'Job not found' } job.resume() return { success: true, message: `Job ${params.name} resumed` } })})CLI Generation
Scaffold new scheduled tasks with the ExisJS CLI:
# Functional job (generates src/cron/cleanup.ts)npx exis generate cron cleanup
# Class-based OOP service (generates src/cron/cleanup.service.ts with @Injectable & @Cron)npx exis generate cron cleanup --oop