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 '../ApplicationScheduledService'
import HourProcessor from './HourProcessor'
export default class ScheduledJobs extends ApplicationScheduledService {
public static async scheduleAllJobs() {
await this.schedule('0 * * * *', 'processHour')
}
public static async processHour() {
await HourProcessor.process(DateTime.now())
}
}
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 mutual import is a cycle and risks class-init / globalName-registration ordering problems.
A scheduled method should do almost nothing itself: select what needs to happen now and fan out to backgrounded services that do the real work. Heavy lifting inside a scheduled method is a design smell. 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() // delegate to a backgrounded service
}
}
// app/services/ReconcileService.ts
export default class ReconcileService extends ApplicationBackgroundedService {
public static async reconcileAll() {
const userIds = await User.where({ syncEnabled: true }).pluck('id')
for (const userId of userIds) {
await IntercomSync.syncUser(userId) // 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 ReconcileService.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 that fans out at runtime:
// RIGHT — schedule the fan-out method ONCE; it loops at run time, not at schedule time
await ReconcileService.schedule('0 13 * * *', 'reconcileAll')
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 select the users whose local end-of-day (or end-of-week) falls in the current hour, then fan 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; figures out whose local end-of-day this hour is
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 User.where({ endOfDayHourUtc: now.hour }).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.