Skip to content

Scheduling

Monque selects due Jobs by priority and supports recurring scheduling using standard 5-field cron expressions.

Pass priority when creating a Job:

await monque.enqueue("send-email", { kind: "digest" }, { priority: -10 });
await monque.now("send-email", { kind: "password-reset" }, { priority: 10 });
await monque.enqueue(
  "send-email",
  { kind: "reminder" },
  {
    priority: 20,
    runAt: new Date(Date.now() + 60_000),
  },
);

Higher numbers take precedence among due pending Jobs with the same Job Name. The default priority is 0; negative numbers put work below that default. Values must be signed JavaScript safe integers. Monque rejects non-number, fractional, non-finite, and unsafe values before writing a Job. The now() shortcut schedules immediately and accepts { priority, session } options. Set priority on each enqueueMany() item or in schedule() options for batch and recurring work.

Equal priorities are ordered by nextRunAt ascending, then _id ascending. A future Job remains ineligible until nextRunAt, regardless of priority. Deadline discovery and future wakeups still follow scheduled time, so future urgent Jobs cannot hide due routine work. Priority compares Jobs within one Job Name. Monque still rotates between Job Names to share instance capacity.

An enqueue using an active uniqueKey returns the existing Job unchanged, including its priority, payload, and scheduled time. Public Job reads, metadata summaries, lifecycle events, and the Dashboard expose effective numeric priority, including default 0.

During initialize(), Monque automatically writes priority: 0 only to Jobs missing the field. Jobs created without a priority then compete at priority zero, including against negative priorities. Repeated initialization preserves existing priorities, timestamps, lifecycle state, and ownership metadata. It runs even with skipIndexCreation: true. You do not need to rewrite existing Jobs before upgrading.

Upgrade every claiming Scheduler Instance and every producer sharing the collection before relying on uniform priority ordering. Older schedulers ignore priority when claiming work, and older producers can create Jobs without a priority after initialization. See the production index guidance for the separate claim and deadline indexes.

Method / TypeDescription
schedule()Create a recurring cron-based job
ScheduleOptionsOptions for schedule()
Job.repeatIntervalCron expression for recurring jobs
InvalidCronErrorThrown for invalid cron expressions

Use schedule() to create recurring jobs:

import { Monque } from "@monque/core";

// Run every hour at minute 0
await monque.schedule("0 * * * *", "hourly-report", {
  type: "analytics",
});
schedule<T>(
  cronExpression: string,  // 5-field cron expression
  name: string,            // Job type name
  data: T,                 // Job payload
  options?: ScheduleOptions
): Promise<PersistedJob<T>>
interface ScheduleOptions {
  timezone?: string; // IANA timezone; defaults to the server's local timezone
  uniqueKey?: string; // Prevent duplicate scheduled jobs
}

Monque uses the standard 5-field cron format:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
│ │ │ │ │
* * * * *
CharacterDescriptionExample
*Any value* * * * * (every minute)
,List separator0,30 * * * * (0 and 30 minutes)
-Range0-15 * * * * (minutes 0-15)
/Step values*/5 * * * * (every 5 minutes)

Monque supports cron aliases provided by the cron-parser library for common schedules:

AliasDescriptionEquivalent
@yearly (or @annually)Run once a year0 0 1 1 *
@monthlyRun once a month0 0 1 * *
@weeklyRun once a week0 0 * * 0
@daily (or @midnight)Run once a day0 0 * * *
@hourlyRun once an hour0 * * * *
// Run daily at midnight using alias
await monque.schedule("@daily", "daily-cleanup", {});
// Every minute
await monque.schedule("* * * * *", "heartbeat-check", {});

// Every 5 minutes
await monque.schedule("*/5 * * * *", "sync-cache", {});

// Every hour at minute 0
await monque.schedule("0 * * * *", "hourly-cleanup", {});

// Every 6 hours
await monque.schedule("0 */6 * * *", "refresh-tokens", {});
// Every day at midnight
await monque.schedule("0 0 * * *", "daily-report", {
  reportType: "sales",
});

// Every day at 9 AM
await monque.schedule("0 9 * * *", "morning-briefing", {
  channels: ["email", "slack"],
});

// Twice daily at 6 AM and 6 PM
await monque.schedule("0 6,18 * * *", "inventory-sync", {});
// Every Monday at 9 AM
await monque.schedule("0 9 * * 1", "weekly-summary", {
  reportType: "performance",
});

// Weekdays at 8 AM
await monque.schedule("0 8 * * 1-5", "workday-startup", {});

// Every Sunday at midnight
await monque.schedule("0 0 * * 0", "weekly-cleanup", {});
// First day of month at midnight
await monque.schedule("0 0 1 * *", "monthly-billing", {});

// 15th of every month at noon
await monque.schedule("0 12 15 * *", "mid-month-review", {});

// Last day approach: run on 28th (safe for all months)
await monque.schedule("0 0 28 * *", "monthly-summary", {});

When you call schedule():

  1. Cron expression is validated
  2. Next run time is calculated from current time
  3. Job is created with repeatInterval set to the cron expression
const job = await monque.schedule("0 * * * *", "hourly-task", { foo: "bar" });

console.log(job.repeatInterval); // '0 * * * *'
console.log(job.nextRunAt); // Next hour, minute 0

When a recurring job completes successfully:

  1. Status changes to completed
  2. Monque calculates next run time from current time
  3. Job is reset to pending with new nextRunAt

For '0 * * * *' (hourly at minute 0):

10:00 - Job created, nextRunAt = 11:00
11:00 - Job runs, completes at 11:02
11:02 - Rescheduled, nextRunAt = 12:00
12:00 - Job runs, fails
12:00 - Retry scheduled, nextRunAt = 12:00 + backoff
12:02 - Retry succeeds
12:02 - Rescheduled, nextRunAt = 13:00

Use uniqueKey to avoid creating another active job with the same name and key:

// Only one hourly report job will exist
await monque.schedule(
  "0 * * * *",
  "hourly-report",
  {},
  {
    uniqueKey: "hourly-report-singleton",
  },
);

// Safe to call multiple times (during app startup, etc.)
await monque.schedule(
  "0 * * * *",
  "hourly-report",
  {},
  {
    uniqueKey: "hourly-report-singleton",
  },
);
// Second call returns existing job, no duplicate created

Monque throws InvalidCronError for invalid expressions:

import { InvalidCronError, Monque } from "@monque/core";

try {
  await monque.schedule("invalid cron", "job", {});
} catch (error) {
  if (error instanceof InvalidCronError) {
    console.log("Invalid expression:", error.expression);
    console.log("Error:", error.message);
    // Error includes: position of error, valid format example
  }
}

Monque provides detailed error messages for invalid expressions:

await monque.schedule("60 * * * *", "job", {});
// InvalidCronError: Invalid cron expression "60 * * * *":
// minute (field 1) value 60 is out of range (0-59)

Pass an IANA timezone to evaluate every occurrence in that zone, independently of the server’s timezone. Monque saves the timezone on the job and uses it again after each successful run. Execution timestamps such as nextRunAt remain JavaScript Date values.

// 09:00 Berlin: 08:00 UTC in winter, 07:00 UTC in summer
await monque.schedule(
  "0 9 * * *",
  "daily-task",
  {},
  {
    timezone: "Europe/Berlin",
    uniqueKey: "daily-task-berlin",
  },
);

Use timezone: 'UTC' for a fixed UTC schedule. An invalid or empty timezone rejects with InvalidCronError before a job is created.

Omitting timezone preserves the existing behavior: cron expressions use the server’s local timezone, including for previously stored jobs. Keep server timezones consistent across workers that process those jobs.

Timezone rules include daylight saving changes. For Berlin’s daily 30 2 * * * schedule, cron-parser moves the missing spring 02:30 occurrence to 03:30 and selects the first 02:30 occurrence during the autumn repeated hour. Missed occurrences are not backfilled; the next occurrence is calculated after successful completion.

import { JobStatus } from "@monque/core";

const recurringJobs = await monque.getJobs({
  status: [JobStatus.PENDING, JobStatus.PROCESSING],
});

const scheduledJobs = recurringJobs.filter((job) => job.repeatInterval);

for (const job of scheduledJobs) {
  console.log(`${job.name}: ${job.repeatInterval} - Next: ${job.nextRunAt}`);
}

Cancel a recurring job while it is pending to stop future runs:

await monque.cancelJob(jobId);

Use deleteJob(jobId) to remove the schedule permanently. A processing job cannot be cancelled, and deleting its document does not interrupt the running handler. Stop the scheduler and wait for handlers to finish if you need to remove an active schedule safely. See Job Management for action constraints.

// ✅ Good
await monque.schedule("0 0 * * *", "daily-user-activity-digest", {});
await monque.schedule("*/15 * * * *", "sync-inventory-with-warehouse", {});

// ❌ Bad
await monque.schedule("0 0 * * *", "job1", {});
await monque.schedule("*/15 * * * *", "sync", {});
await monque.schedule("0 9 * * 1", "weekly-report", {
  reportType: "sales",
  recipients: ["team@example.com"],
  includePreviousWeek: true,
});

Use uniqueKey when registering a long-running schedule to avoid creating duplicate active jobs:

// If job takes longer than 1 hour, next run waits
await monque.schedule(
  "0 * * * *",
  "long-running-sync",
  {},
  {
    uniqueKey: "long-sync-singleton",
  },
);
monque.on("job:complete", ({ job }) => {
  if (job.repeatInterval) {
    console.log(`Recurring job ${job.name} completed. Next run: ${job.nextRunAt}`);
  }
});