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 prop | Type | Default | Mode |
|---|---|---|---|
schedule.startAfterSeconds | number | — (runs immediately) | one-off, delayed by N seconds |
schedule.startEverySeconds | number | — (not recurring) | recurring, fixed interval in seconds |
schedule.cron | CronSpec | — (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.
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.
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.
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.
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.
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.