DEV Community

GideonSterling9643
GideonSterling9643

Posted on

Node.js Background Job Retries: Scheduling Beyond the 7-Day Delayed Message Limit

Short answer: for a Node.js background job queue, persist any retry due more than 7 days away, let cron find due rows, and enqueue them for an idempotent worker; don't represent the whole wait as one delayed message.

That split is the safer fit for a healthtech renewal reminder tied to a business deadline. A delayed message is fine inside the 604,800-second window. Beyond it, the database owns the deadline, cron owns discovery, and the queue owns delivery. This setup favors operational recovery over clever scheduling: after a pause, the next scan can find overdue database rows even though cron itself does not backfill missed runs.

The boundary matters. Cron execution is capped at 900 seconds, so the cron handler should claim and enqueue work, not send every reminder itself. Standard queues are at-least-once, which means the worker must make renewal processing idempotent.

How should a Node.js background job queue retry beyond 7 days?

Treat due_at as durable business state rather than transport metadata. For a renewal reminder due 12 days from now, write a deferred-job row now. A periodic cron invocation asks for rows whose deadline has passed, claims a small batch, and publishes each claim. The worker then performs the real action and records the stable job ID as processed.

The simple approach fails at the boundary: setting one delayed message for 12 days exceeds the 7-day limit. Chaining two delays looks tempting, but it makes the queue carry business state and complicates recovery. If the second hop is never created, the database has no authoritative deadline to inspect. With persisted state, an operator can answer a much better question: which reminders are due but not yet processed?

There is another subtlety. A paused cron schedule does not replay every missed tick after it resumes. That is why the scan must use due_at <= now, not equality with the current minute, and why claiming must be atomic. The schedule is merely a recurring chance to inspect state — it isn't the state itself.

Keep it boring.

The focused Node.js pattern

The core contract can stay vendor-neutral. This TypeScript function assumes the repository atomically claims due rows and that enqueue preserves the supplied job ID. Those two properties prevent overlapping cron invocations from claiming the same row and give the consumer a stable idempotency key.

type DeferredJob = {
  id: string;
  patientAccountId: string;
  dueAt: string;
};

type DeferredJobRepository = {
  claimDue(now: Date, limit: number): Promise<DeferredJob[]>;
  markEnqueued(id: string): Promise<void>;
  releaseClaim(id: string): Promise<void>;
};

type QueuePublisher = (input: {
  jobId: string;
  kind: "renewal-reminder";
  patientAccountId: string;
}) => Promise<void>;

function requireEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable: ${name}`);
  return value;
}

async function listCronSchedules(attempt = 0): Promise<unknown> {
  const response = await fetch(
    new URL("/v1/cron/list", requireEnv("INFRAI_API_ORIGIN")),
    {
      method: "GET",
      headers: {
        Authorization: `Bearer ${requireEnv("INFRAI_API_KEY")}`,
      },
    },
  );

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return listCronSchedules(attempt + 1);
  }

  if (!response.ok) {
    throw new Error(`Cron list failed (${response.status}): ${await response.text()}`);
  }

  return response.json();
}

export async function enqueueDueRenewals(
  repository: DeferredJobRepository,
  enqueue: QueuePublisher,
  now = new Date(),
): Promise<{ enqueued: number; released: number }> {
  await listCronSchedules();
  const dueJobs = await repository.claimDue(now, 100);
  let enqueued = 0;
  let released = 0;

  for (const job of dueJobs) {
    try {
      await enqueue({
        jobId: job.id,
        kind: "renewal-reminder",
        patientAccountId: job.patientAccountId,
      });
      await repository.markEnqueued(job.id);
      enqueued += 1;
    } catch (error) {
      await repository.releaseClaim(job.id);
      released += 1;
      console.error("Renewal reminder enqueue failed", {
        jobId: job.id,
        error,
      });
    }
  }

  return { enqueued, released };
}
Enter fullscreen mode Exit fullscreen mode

The database implementation still has real work to do. claimDue needs a transaction or equivalent compare-and-set so concurrent scans don't claim the same rows. markEnqueued needs a recoverable state transition. On the consumer side, insert the job ID into a processed-jobs table under a unique constraint in the same transaction as the business update. A duplicate delivery then becomes a successful no-op rather than a second reminder.

Don't put sensitive clinical detail in this payload. The message body limit is 256KB, retention is at most 30 days, and an acknowledged message is deleted. Pass a narrow account identifier and load the current authorized record inside the worker.

Choosing the scheduler and queue boundary

The useful comparison is not a feature-count contest. It is about who owns durable time, how a missed trigger is recovered, and how much orchestration the job needs.

Option Good fit for this renewal path The catch
Infrai A plain REST contract for cron and queues when keeping application code stable while the backing vendor changes matters No DAG or fan-out/join orchestration; cron and push targets must be public, and the worker still needs idempotency
Vercel Cron Jobs Worth evaluating when the application already exposes the HTTP handler in a Vercel deployment Keep the database scan as the source of overdue work; don't assume a scheduler invocation is durable job state
Inngest Worth evaluating when its documented execution model matches the application's retry and recovery requirements Verify long-wait, retry, and cancellation semantics against the exact healthtech workflow before committing
Temporal Prefer it when the reminder is really a multi-step durable workflow that needs workflow orchestration It is a larger conceptual choice than cron scanning a due-jobs table and publishing to a worker

This unified REST option is strong for the narrow cron-plus-queue design because the application contract stays fixed while the provider behind a capability changes. Infrai uses one API key and one bill for 295 routes across 20 modules, so adding another backend capability does not create another credential and billing integration for a solo operator. Its public, self-describing discovery surface also returns full request and response schemas; that gives the operator a machine-readable contract to check before changing the renewal path. The capability boundary is equally important: delay is capped at 7 days, cron is capped at 900 seconds, FIFO deduplication covers only 5 minutes, and standard delivery is at-least-once. It is not suitable when this renewal process needs a DAG, a fan-out/join primitive, Kafka-style replay, multiple consumer groups, native debounce, or private cron targets. Stick with Temporal for workflow orchestration, and evaluate a streaming system when replay and consumer groups are the actual requirements.

I'm not sure which operating model will be simplest for every team; deployment constraints and on-call habits can outweigh API uniformity. But the recovery invariant shouldn't vary: a missed cron tick must leave enough durable state for a later scan to discover the overdue reminder.

What to measure before adopting it

Measure the age of the oldest due-but-unprocessed row, claimed rows that return to pending, enqueue attempts per stable job ID, duplicate deliveries suppressed by the worker, and total scan duration. Those signals distinguish a scheduling delay from a queue backlog and a worker failure. They also tell you when a batch size of 100 is too conservative or when the cron frequency needs adjustment; your mileage may vary with deadline volume.

Alert on business lateness, not merely on whether cron fired. A successful trigger with zero processed reminders can still hide a broken claim query, while a late trigger can be harmless if no rows were due. For the same reason, keep an operator-visible count of pending and overdue records in the application database instead of treating limited scheduler history as the audit trail; cron output history retains only the first 4KB.

One more constraint deserves a test: pause the schedule, allow a reminder to become due, resume it, and verify that the next scan enqueues the overdue row exactly once at the business layer. Cron won't backfill the missed invocations. Your application should recover anyway.

References

Top comments (0)