DevOps
Apr 2026•10 min read

Resilient Background Jobs with Node-Cron & Redis Queues

Scheduling automated crawlers, fault-tolerant batch workers, and distributed locking

AP

Ashish Pal

Full Stack Developer

Resilient Background Jobs with Node-Cron & Redis Queues

Executive Takeaways & Key Concepts

  • Use distributed locks (Redlock) to prevent duplicate cron executions across scaled container clusters.
  • Transition long-running batch jobs into BullMQ / Redis Streams with exponential retry strategies.
  • Isolate CPU-intensive background tasks from the main HTTP API thread to prevent request blocking.
  • Implement Dead-Letter Queues (DLQ) with automated alerting for unrecoverable worker failures.

1. The Danger of In-Memory Cron in Clustered Environments

Why node-cron without distributed synchronization triggers catastrophic duplicate jobs

When deploying a Node.js web application to ECS, Kubernetes, or Auto-Scaling EC2 groups, multiple replica pods run in parallel. If an in-memory scheduler like `node-cron` triggers a job (e.g. sending daily digest emails or scraping job listings), every single container will execute the job simultaneously, causing duplicate billing, email spam, and database race conditions.

To safely run scheduled tasks in distributed environments, you must either use an external event dispatcher (AWS EventBridge / Cloud Tasks) or synchronize execution across instances using a distributed lock pattern.

2. Implementing Distributed Locking with Redis (Redlock)

Acquiring atomic execution tokens across clustered worker nodes

Using Redis atomic commands (`SET key value NX PX timeout`), only the first container to request the lock will receive authorization to run the scheduled task. Other instances immediately skip execution.

workers/cronScheduler.ts
typescript
import cron from "node-cron";
import { Redis } from "ioredis";
import { runEngineeringJobScraper } from "../scrapers/jobScraper";

const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");

export function initDistributedCron() {
  // Schedule to run every 2 hours
  cron.schedule("0 */2 * * *", async () => {
    const lockKey = "locks:cron:engineering-scraper";
    const lockTtlMs = 1000 * 60 * 30; // 30 minutes lease
    const workerInstanceId = `worker-${process.env.HOSTNAME || Math.random().toString(36).substring(7)}`;

    // SET key value NX PX: Atomic acquisition
    const acquired = await redis.set(lockKey, workerInstanceId, "PX", lockTtlMs, "NX");

    if (!acquired) {
      console.log(`[${workerInstanceId}] Scraper job is already running on another node. Skipping.`);
      return;
    }

    console.log(`[${workerInstanceId}] Lock acquired. Executing scheduled scraper...`);
    const startTime = Date.now();

    try {
      const results = await runEngineeringJobScraper();
      console.log(`[${workerInstanceId}] Successfully scraped ${results.totalJobs} jobs in ${Date.now() - startTime}ms`);
    } catch (error) {
      console.error(`[${workerInstanceId}] Scraper error:`, error);
      // Optional: alert PagerDuty / Sentry
    } finally {
      // Release lock safely with Lua script to verify lock ownership
      const releaseScript = `
        if redis.call("get", KEYS[1]) == ARGV[1] then
          return redis.call("del", KEYS[1])
        else
          return 0
        end
      `;
      await redis.eval(releaseScript, 1, lockKey, workerInstanceId);
      console.log(`[${workerInstanceId}] Lock released.`);
    }
  });
}

3. Scaling from Simple Cron to Resilient BullMQ Job Queues

Handling exponential backoffs, concurrency limits, and worker pools

When tasks require processing thousands of sub-items (e.g. resizing images, generating clinical PDFs, dispatching FCM notifications), running them in a single monolithic cron loop risks timeouts.

Decomposing the batch into distinct jobs managed by BullMQ allows workers to process jobs concurrently with automatic retry logic and dead-letter queue isolation.

queues/notificationQueue.ts
typescript
import { Queue, Worker, QueueEvents } from "bullmq";
import { redisConnection } from "../lib/redis";
import { sendPushNotification } from "../services/fcm";

export const notificationQueue = new Queue("push-notifications", {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 4,
    backoff: {
      type: "exponential",
      delay: 2000,
    },
    removeOnComplete: 1000,
    removeOnFail: 5000,
  },
});

// Dedicated background worker process
export const notificationWorker = new Worker(
  "push-notifications",
  async (job) => {
    const { userId, title, body, metadata } = job.data;
    await sendPushNotification(userId, { title, body, metadata });
  },
  {
    connection: redisConnection,
    concurrency: 25, // Process 25 notifications concurrently
  }
);
#Node.js#node-cron#Redis#BullMQ#Microservices#Event-Driven

More Engineering Notes

View All
Connect