Skip to content

Retry & Backoff

When jobs fail with ordinary errors, Monque automatically retries them using exponential backoff. Retry delays give an unavailable service time to recover. Jobs become failed when they reach the configured failure limit.

Option / PropertyDefaultDescription
maxRetries10Failure count at which a job becomes permanently failed
baseRetryInterval1000msBase delay for backoff calculation
maxBackoffDelay86400000msMaximum backoff cap (24h)
Job.failCount-Number of failed attempts
Job.failReason-Last error message
  1. Worker handler throws an error or returns rejected promise
  2. failCount is incremented
  3. failReason is set to the error message
  4. If the error is NonRetryableError or failCount >= maxRetries: mark as failed
  5. Otherwise: schedule retry with backoff
baseDelay = min(2^failCount × baseRetryInterval, maxBackoffDelay)
nextRunAt = now + min(jitter(baseDelay), maxBackoffDelay)

The delay varies randomly by ±25% around baseDelay, then is capped at maxBackoffDelay. This spreads retries when many jobs fail together.

With default settings (baseRetryInterval: 1000ms, maxRetries: 10), a job can run once initially and retry up to nine times. The tenth failure is terminal. Delays below are before jitter:

Failed attemptfailCountDelay before next attempt
Initial run12s
1st retry24s
2nd retry38s
3rd retry416s
4th retry532s
5th retry664s
6th retry7128s
7th retry8256s
8th retry9512s
9th retry10No retry; failed permanently

Configure retry behavior in MonqueOptions:

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

const monque = new Monque(db, {
  maxRetries: 10, // Default: 10
  baseRetryInterval: 1000, // Default: 1000ms
  maxBackoffDelay: 3600000, // Optional override: cap at 1 hour (default cap is 24 hours)
});

Override the same options when registering a worker:

monque.register("send-email", emailHandler, {
  maxRetries: 5,
  baseRetryInterval: 2000,
  maxBackoffDelay: 60000,
});

Each omitted value inherits the scheduler setting. maxRetries: 0 or 1 makes the first failure terminal; NonRetryableError always skips retries. Worker overrides use the same failure-count and jitter rules as scheduler settings.

Settings belong to the local worker, not the stored job. Configure workers for the same job name consistently across scheduler instances. Replacing a worker affects future executions; an active execution keeps the retry settings it started with.

Ts.ED forwards these settings from @Job('send', { maxRetries: 5 }); see Ts.ED integration.

OptionDefaultDescription
maxRetries10Failure count at which retries stop
baseRetryInterval1000 (1s)Base delay multiplied by backoff factor
maxBackoffDelay86400000Maximum delay cap (24 hours)

Subscribe to job:fail events:

monque.on("job:fail", ({ job, error, willRetry }) => {
  console.log(`Job ${job.name} failed: ${error.message}`);
  console.log(`Attempt: ${job.failCount}`);
  console.log(`Will retry: ${willRetry}`);

  if (willRetry) {
    console.log(`Next attempt at: ${job.nextRunAt}`);
  } else {
    console.log("Job failed without further automatic retries");
    // Alert, log to error tracker, etc.
  }
});
monque.register("example", async (job) => {
  if (job.failCount > 0) {
    console.log(`Retry attempt ${job.failCount}`);
    console.log(`Previous error: ${job.failReason}`);
  }

  // Processing logic
});

Throw NonRetryableError when retrying cannot resolve the problem:

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

monque.register<{ accountId: string }>("send-report", async (job) => {
  const account = await findAccount(job.data.accountId);
  if (!account) {
    throw new NonRetryableError("Account no longer exists");
  }
  await sendReport(account);
});

Requires @monque/core 1.14.0 or later on every worker using this error. Import the class from the same installed core package used by your scheduler; matching an error’s name alone does not disable retries. Subclasses of NonRetryableError also stop retries.

The job becomes failed immediately. failCount increases by one, failReason stores the message, and job:fail reports willRetry: false. Ordinary errors keep the configured backoff policy. Catching an error and returning normally still records a successful run.

For recurring jobs, this stops the recurring job as well: no next occurrence is scheduled, matching exhausted-retry behavior. The cron expression and timezone remain available.

After fixing the cause, you can use retryJob(), bulk retry, the Management API, or Dashboard’s Retry action. Manual retry resets the failure count and runs the job again; a successful recurring job then resumes its schedule. The error does not permanently prohibit operator retries.

Management and Dashboard use the existing failed status, failure reason, and attempt count. No migration or new status is needed; persisted jobs do not record a separate non-retryable flag.

const circuitBreaker = {
  failures: 0,
  lastFailure: 0,
  threshold: 5,
  resetTime: 60000, // 1 minute
};

monque.register("external-api", async (job) => {
  // Check circuit breaker
  const now = Date.now();
  if (circuitBreaker.failures >= circuitBreaker.threshold) {
    if (now - circuitBreaker.lastFailure < circuitBreaker.resetTime) {
      throw new Error("Circuit breaker open");
    }
    // Reset after cool-down
    circuitBreaker.failures = 0;
  }

  try {
    await callExternalApi(job.data);
    circuitBreaker.failures = 0; // Success resets counter
  } catch (error) {
    circuitBreaker.failures++;
    circuitBreaker.lastFailure = now;
    throw error;
  }
});

Recurring jobs use the same retry delays, then return to their cron schedule:

  1. Failed job retries with exponential backoff
  2. After successful completion (including successful retry), job reschedules using original cron
  3. The next cron occurrence is calculated after the successful run; missed occurrences are not replayed
Cron: '0 * * * *' (hourly)

14:00 - Job runs, fails
14:00 - Retry 1 scheduled for 14:00 + 2s = 14:00:02
14:00:02 - Retry runs, succeeds
14:00:02 - Next run scheduled for 15:00 (cron schedule resumes)
// Mission-critical: more retries
const criticalMonque = new Monque(db, {
  maxRetries: 20,
  maxBackoffDelay: 3600000, // Cap at 1 hour
});

// Background tasks: fewer retries
const backgroundMonque = new Monque(db, {
  maxRetries: 3,
  baseRetryInterval: 5000, // Start slower
});
monque.register("important-job", async (job) => {
  const logger = createLogger({
    jobId: job._id,
    attempt: job.failCount + 1,
    maxRetries: 10,
  });

  logger.info("Processing job");

  try {
    await process(job.data);
    logger.info("Job completed");
  } catch (error) {
    logger.error("Job failed", { error: error instanceof Error ? error.message : String(error) });
    throw error;
  }
});
monque.on("job:fail", async ({ job, error, willRetry }) => {
  if (!willRetry) {
    // Move to dead letter queue for manual review
    await db.collection("dead_letter_queue").insertOne({
      originalJob: job,
      failedAt: new Date(),
      finalError: error.message,
      totalAttempts: job.failCount,
    });

    // Alert on-call
    await alerting.send({
      severity: "high",
      message: `Job ${job.name} permanently failed after ${job.failCount} attempts`,
      context: { jobId: job._id, error: error.message },
    });
  }
});

Add a random delay if many workers might retry a service at the same time:

monque.register("high-volume-job", async (job) => {
  if (job.failCount > 0) {
    // Add random delay 0-1000ms to spread out retries
    const jitter = Math.random() * 1000;
    await new Promise((resolve) => setTimeout(resolve, jitter));
  }

  await processJob(job.data);
});

Set maxRetries: 0 for jobs that should never retry:

const noRetryMonque = new Monque(db, {
  maxRetries: 0,
});

// Or handle in worker
monque.register("fire-and-forget", async (job) => {
  try {
    await sendNotification(job.data);
  } catch (error) {
    // Log but don't throw - job will complete without retry
    console.error("Notification failed:", error);
  }
});