Skip to main content

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.