Skip to main content

Delayed & recurring jobs

Scheduling lives on addJob itself — there is no separate schedule() method. One method covers one-off and recurring production, selected by which prop you set under the schedule group:

schedule propTypeDefaultMode
schedule.startAfterSecondsnumber— (runs immediately)one-off, delayed by N seconds
schedule.startEverySecondsnumber— (not recurring)recurring, fixed interval in seconds
schedule.cronCronSpec— (not recurring)recurring, cron expression

Omit the schedule group entirely — the default — and the job is one-off and runs immediately.

The timing props are mutually exclusive: a one-off delay never combines with a recurring mode.

Delayed (one-off)​

schedule.startAfterSeconds delays a one-off job by that many seconds from now:

await pq.addJob({ queue: 'emails', data, schedule: { startAfterSeconds: 86_400 } }); // 24 hours, in seconds

Recurring — cron with timezone​

A recurring add creates/updates a recurring producer; data and every job option you pass become the template for each produced run. schedule.cron is either a bare expression string (evaluated in UTC) or { pattern, tz } for a timezone:

const reportsJob = await pq.addJob({
queue: 'reports',
data: { kind: 'sales' }, // template for each run
priority: 5, // job options apply to each run
retry: { maxAttempts: 3 }, // …as do retry, backoff, expireInSeconds, retention, etc.
expireInSeconds: 600, // 10 minutes — durations are always whole seconds
schedule: { cron: { pattern: '0 8 * * 1-5', tz: 'America/New_York' } },
});
// Bare UTC form: schedule: { cron: '0 8 * * 1-5' }
// Cron: 5-field (min hour dom month dow) or 6-field (leading seconds). Parsing is `cron-parser`,
// so ranges 1-5, lists 1,3,5, steps */10, names (MON, JAN) and the specials L # W ? all work.
// tz is an IANA zone (default UTC), DST-correct.
// reportsJob.jobId is absent (no concrete job yet); reportsJob.scheduleId is stamped on every produced job;
// reportsJob.nextRunAt is the first run time.
Catch-up after downtime — fire once, no backfill

Cron and interval recurrences are fire-once with no backfill. If the process was down across one or more due slots, on restart the schedule fires a single time and then advances to the next future slot — missed occurrences are skipped, never replayed.

Options shape the spawned jobs, not the schedule

A schedule spawns a new job on each tick, and every option you pass to the recurring addJob — priority, retry/backoff, expireInSeconds, retention, singletonKey, preferredInstanceKeyPattern — is carried onto each spawned job. These govern the produced jobs, never the schedule itself: expireInSeconds bounds how long each run may wait before it starts (re-applied relative to that run), retention controls how long each finished run is kept, and so on. The schedule keeps firing regardless. (One exception: idempotencyKey is not copied to the runs — otherwise a schedule could only ever produce its first job.)

Identical schedules are allowed. Re-adding the same schedule.cron/schedule.startEverySeconds on a queue creates a new, independent schedule — PortQueue does not deduplicate by recurrence pattern. If you want a queue to fire the same pattern twice (say, two different payload templates on the same cron), just add it twice. To keep exactly one, opt in with an idempotencyKey (next section).

Recurring — fixed interval​

// startEverySeconds is a plain number of seconds between runs.
await pq.addJob({ queue: 'healthchecks', data: {}, schedule: { startEverySeconds: 30 } });

startEverySeconds schedules the next run at now + interval at the moment the current one fires (not when it completes), so runs don't drift by handler duration.

The tick sets the resolution

Due runs are materialized on the shared maintenance tick — tickIntervalSeconds, 5 by default — so a recurrence fires on the first tick at or after its due time. An interval (or cron slot) shorter than the tick therefore can't fire more often than the tick: with the default, startEverySeconds: 3 effectively runs every 5s. Lower tickIntervalSeconds for finer resolution.

Overlapping runs

If a handler takes longer than the interval, the next run can start before the previous one finishes, so runs overlap. Guard against this by giving the job a singletonKey — only one run per key is active at a time.

Deduplicating with idempotencyKey​

Because identical recurrences coexist, the way to guarantee only one producer is to give it an idempotencyKey. The key is the dedup identity, scoped to the queue, and it is first-writer-wins:

const firstJob = await pq.addJob({ queue: 'reports', data: { kind: 'sales' }, schedule: { cron: '0 8 * * *' }, idempotencyKey: 'daily-sales' });
const secondJob = await pq.addJob({ queue: 'reports', data: { kind: 'sales' }, schedule: { cron: '0 8 * * *' }, idempotencyKey: 'daily-sales' });
// secondJob.deduplicated === true; secondJob.scheduleId === firstJob.scheduleId.
// The existing schedule is kept **unchanged** — a re-add never mutates it.

The key is per entity: a one-off addJob dedups against the jobs table, and a recurring addJob (with cron/startEverySeconds) dedups against the schedule table. addJob looks only in the table that matches the add's shape; if a matching entity already carries the key, nothing new is created and the existing one is returned with deduplicated: true and deduplicationReason: 'IDEMPOTENCY'.

  • A recurring add dedups only against existing schedules — a one-off job with the same key never blocks it.
  • A one-off add dedups only against existing jobs — a schedule with the same key never blocks it.

So a one-off job and a schedule may safely share a key on the same queue; they never dedup against each other. And the per-run jobs a schedule produces are not stamped with the key — otherwise a recurring schedule could only ever produce its first run — so those runs never collide with the schedule's own key.

Without an idempotencyKey, every add creates a fresh job or schedule. getSchedules() surfaces each schedule's idempotencyKey for inspection.

Unscheduling​

Unschedule a recurring definition by its scheduleId:

await pq.removeSchedule({ scheduleId: reportsJob.scheduleId });

removeSchedule applies only to recurring schedules — it stops future production but does not delete jobs already produced (remove those by id or status if you want). It is its own method (not removeJob) per the entity-naming rule: a method names the one entity it acts on.

Changing a schedule = remove + re-add

There is no "edit schedule" API. The only way to change a schedule (a different cron pattern, interval, timezone, or template) is to remove it (pq.removeSchedule({ scheduleId })) and add a new one (pq.addJob({ queue, ..., schedule })). Remove + re-add is the sole path.

For a one-off job there is no schedule to remove — use the job-level methods instead: pq.cancelJob({ jobId }) to cancel a not-yet-active job, or pq.removeJob({ jobId }) to delete the row outright. See Cancel a job and Remove jobs.

Inspecting​

List a queue's recurring definitions — each carries the scheduleId you pass to removeSchedule:

const schedules = await pq.getSchedules({ queue: 'reports' }); // { items, total }

// Paged, and narrowed to the armed ones:
const armed = await pq.getSchedules({ queue: 'reports', enabled: true, limit: 20, offset: 0 });

getSchedules returns a Page<ScheduleInfo>: items for this window and total for every schedule matching the filter. Another page follows when offset + items.length < total.