Scheduled Services
In your application, you may find yourself needing to run hourly, weekly, daily jobs, etc... In these contexts, you can leverage your ApplicationScheduledService class to schedule using a beautiful, cron-driven api, courtesy of bullmq under the hood.
import ApplicationScheduledService from '#services/ApplicationScheduledService.js'
import HourProcessor from '#services/HourProcessor.js'
export default class ScheduledJobs extends ApplicationScheduledService {
public static async scheduleAllJobs() {
await this.schedule('0 * * * *', 'processHour')
}
public static async processHour() {
await HourProcessor.process(DateTime.now()) // enqueues and returns
}
}
Call scheduleAllJobs() yourself, from db/seed.ts
Nothing registers your schedules for you. A service that defines scheduleAllJobs() and never has it called registers nothing, and every cron job silently never runs. Call it from db/seed.ts:
// db/seed.ts
import ScheduledJobs from '#services/ScheduledJobs.js'
export default async function seed() {
if (AppEnv.isTest) return
await ScheduledJobs.scheduleAllJobs()
}
schedule() upserts (keyed `${globalName}:${method}`), so registration is idempotent and belongs wherever a deploy already reconciles the database to the code — which is why seed runs immediately after migrations in every environment (see Deployment overview — combine db:migrate and db:seed). A pipeline that migrates without seeding leaves Redis holding the previous schedule set, so pair the two commands everywhere they run.
Scheduled services are thin orchestrators, not workers
A service class should be either scheduled or backgrounded, not both — ApplicationScheduledService exposes schedule() but not background(), and ApplicationBackgroundedService exposes background() but not schedule(). This is a hard architectural constraint, not a style choice: to fan out, the scheduled (orchestrator) service calls the public entry method of a separate backgrounded (worker) service. Import direction is one-way — the orchestrator imports the worker, never the reverse.
A scheduled method only enqueues its work and returns. It calls entry points on backgrounded services that do nothing but background(...), and every query, selection, and write runs in those jobs. Work done inside the scheduled call ties the failure of one scheduled job to the failure of all: one throw skips everything after it in that run. The goal is to keep the number of permanently-registered schedulers small and fixed — typically just hourly, daily, and weekly — each of which kicks off whatever backgrounded jobs that cadence requires, rather than a sprawling list of per-task schedulers.
// app/services/ScheduledJobs.ts
export default class ScheduledJobs extends ApplicationScheduledService {
public static async scheduleAllJobs() {
await this.schedule('0 * * * *', 'processHour')
}
public static async processHour() {
await ReconcileService.reconcileAll() // enqueues and returns
}
public static async reconcileForms() {
await ReconcileService.reconcileAll() // enqueues and returns
}
}
// app/services/ReconcileService.ts
export default class ReconcileService extends ApplicationBackgroundedService {
public static async reconcileAll() {
await this.background('_reconcileAll')
}
// the selection runs here, inside the job
public static async _reconcileAll() {
await User.where({ syncEnabled: true }).findEach(async user => {
await IntercomSync.syncUser(user) // one job per item
})
}
}
Pitfall: schedule() keys by class+method only — looping silently drops jobs
schedule() registers the BullMQ job scheduler under the id `${globalName}:${method}` — the args are not part of the id. Internally it's create-or-replace, so scheduling the same method more than once with different args does not create multiple schedulers; each call overwrites the last, and only the final args survive:
// WRONG — registers exactly ONE scheduler (for FORM_CONFIGS[last]); the rest silently never run
for (const config of FORM_CONFIGS) {
await ScheduledJobs.schedule('0 13 * * *', 'reconcileForm', config.key)
}
This fails silently — no error, no warning, the worker boots clean — and surfaces only when N−1 of N jobs never happened in production. To schedule N variants, either register N distinct (class, method) pairs, or — the idiomatic fix — schedule one method once whose backgrounded job fans out at runtime:
// RIGHT — schedule one method ONCE; the backgrounded job it enqueues loops at run time
// (reconcileForms calls ReconcileService.reconcileAll(), whose _reconcileAll job does the looping)
await ScheduledJobs.schedule('0 13 * * *', 'reconcileForms')
Per-user cadence across time zones
"Daily" and "weekly" work for users spread across time zones isn't a daily/weekly cron. Register the orchestrator on an hourly cron and have each run enqueue a job that selects the users whose local end-of-day (or end-of-week) falls in the current hour, then fans out one backgrounded job per selected user. Bake the time zone — and any end-of-week preference — into the query so the database returns just the relevant user IDs, rather than loading all users and filtering in code:
// app/services/ScheduledJobs.ts
// Orchestrator — runs every hour; enqueues the work for this hour and returns
export default class ScheduledJobs extends ApplicationScheduledService {
public static async scheduleAllJobs() {
await this.schedule('0 * * * *', 'endOfDayFanOut') // hourly, despite being "daily" work
}
public static async endOfDayFanOut() {
await EndOfDayService.fanOut(DateTime.now())
}
}
// app/services/EndOfDayService.ts
// Worker — selects matching users by time zone, enqueues one job each
export default class EndOfDayService extends ApplicationBackgroundedService {
public static async fanOut(now: DateTime) {
await this.background('_fanOut', now.toISO())
}
public static async _fanOut(nowIso: string) {
// timeZonesAtLocalHour is an app helper returning the time zones whose
// local hour at this instant is END_OF_DAY_HOUR. Selecting IDs only,
// never hydrating full records.
const timeZones = timeZonesAtLocalHour(DateTime.fromISO(nowIso), END_OF_DAY_HOUR)
await User.where({ timeZone: timeZones }).pluckEach('id', async (id: string) => {
await this.background('_runEndOfDay', id)
})
}
public static async _runEndOfDay(id: string) {
const user = await User.find(id)
if (!user) return
// ...the per-user end-of-day work
}
}
End-of-week works the same way, with the user's chosen end-of-week day folded into the query alongside the time zone, so a single hourly orchestrator covers every user's preference without a separate scheduler per variant.
Scheduled and backgrounded methods run inline in tests
In NODE_ENV=test with the default testInvocation: 'automatic', background(...) and backgroundWith(...) invoke the underlying method immediately and synchronously — the delay is ignored (see Testing). A spec that calls either executes the work with no queue flush needed. schedule(...) is not inline work: in test as in every other environment it registers a BullMQ job scheduler, so a spec that calls it runs nothing. The flip side: any environment guard inside the method (e.g. if (serverEnvironment !== 'production') return) also fires in tests, so a guarded method needs a force-style override to be exercised in a spec.