·QueueHub Team·15 min read

Database-Backed Dynamic Schedules with BullMQ: A Practical Guide to Runtime Schedule Management

bullmqjob-schedulingdatabase-backed-schedulesruntime-managementmulti-tenantcrontypescript

Hardcoded cron patterns work fine when your application has three schedules that never change. But the moment you need to let users configure their own report delivery timing, or you're running a SaaS platform where each tenant picks their own sync windows, hardcoded schedules break immediately.

The solution: Store your schedule configurations in a database and sync them to BullMQ Job Schedulers at runtime. Your users create schedules via an API, your database stays the authoritative source of truth, and BullMQ handles the actual execution — all without restarting a single worker.

In this guide, you'll learn a production-ready pattern for managing BullMQ schedules dynamically at runtime. We'll cover database schema design, a full CRUD API, boot-time sync, runtime mutation sync, multi-tenant isolation, monitoring, and performance at scale — with complete TypeScript code examples throughout.

What this is NOT: This is not another introduction to BullMQ's delayed jobs, repeatable jobs, or cron syntax. Those are well covered in our other guides. This post assumes you already know how BullMQ Job Schedulers work and focuses on the architecture of managing them dynamically.


Architecture Overview — The Sync Pattern

The core idea is simple: a database is the source of truth, BullMQ Job Schedulers are the execution engine, and a sync layer bridges the two.

┌─────────────────┐     ┌──────────────┐     ┌─────────────────┐
│   PostgreSQL /   │     │  Sync Layer  │     │  BullMQ Queue   │
│    SQLite DB     │────▶│  (Scheduler  │────▶│  (Job Scheduler │
│  (source of      │     │   Service)   │     │   Instances)    │
│   truth)         │     │              │     │                 │
└─────────────────┘     └──────────────┘     └─────────────────┘
        │                       │                      │
        │  CRUD API             │  upsert / remove      │  produces jobs
        ▼                       ▼                      ▼
  ┌──────────────┐      ┌──────────────┐      ┌─────────────────┐
  │  REST / gRPC  │      │  Boot Sync   │      │     Worker      │
  │  Endpoints    │      │  + Event     │      │  (processes     │
  │               │      │  Listeners   │      │   jobs)         │
  └──────────────┘      └──────────────┘      └─────────────────┘

Key Design Principles

  1. Database is authoritative — BullMQ state is always derived from DB state. If they disagree, you trust the DB and sync again.
  2. Idempotent operations — BullMQ's upsertJobScheduler makes runtime sync trivial. Call it a hundred times with the same data — the result is the same.
  3. Single direction of sync — From DB to BullMQ, never the reverse. This avoids split-brain scenarios.
  4. Graceful degradation — A failed BullMQ sync never corrupts the DB record. The reconciliation cron picks up failures later.

Database Schema Design

Your schedule storage needs to capture everything BullMQ's RepeatOptions supports, plus metadata for multi-tenant management, versioning, and soft-delete.

// prisma/schema.prisma
model Schedule {
  id         String   @id @default(uuid())
  tenantId   String?           // null for single-tenant setups
  name       String            // human label, e.g. "weekly-digest"
  pattern    String?           // cron, e.g. "0 9 * * 1"
  every      Int?              // ms interval, alternative to pattern
  tz         String?  @default("UTC")
  startDate  DateTime?
  endDate    DateTime?
  limit      Int?              // max executions
  immediately Boolean @default(false)
  jobName    String            // name for produced jobs
  jobData    Json?             // default payload
  jobOptions Json?             // attempts, backoff, removeOnComplete, etc.
  enabled    Boolean  @default(true)
  version    Int      @default(1)
  metadata   Json?

  createdAt  DateTime @default(now())
  updatedAt  DateTime @updatedAt
  deletedAt  DateTime?

  @@unique([tenantId?, name])
  @@index([tenantId, enabled])
  @@index([enabled, deletedAt])
}

Key design decisions here:

  • tenantId enables multi-tenant isolation without separate tables. Null for single-tenant.
  • pattern and every are mutually exclusive — validated at the application layer, enforced by BullMQ's RepeatOptions.
  • version enables optimistic concurrency control, preventing lost updates when two API clients modify the same schedule simultaneously.
  • deletedAt enables soft-delete with audit trail. Hard deletions (which also call removeJobScheduler) could be done on a separate archive job.
  • (tenantId, name) unique constraint prevents duplicate scheduler IDs per tenant — it maps directly to BullMQ's scheduler key naming scheme.

The Schedule Service — Your CRUD + Sync Engine

The ScheduleService class wraps all database operations and automatically syncs changes to BullMQ. Here are the core operations with a type-safe interface:

// types/schedule.ts
export function buildSchedulerId(tenantId: string | null, name: string): string {
  return tenantId ? `tenant:${tenantId}:${name}` : name;
}

CREATE Operation

When a user creates a new schedule, the service writes to the database first, then syncs to BullMQ:

// ScheduleService — simplified CREATE
async create(input: CreateScheduleInput): Promise<ScheduleConfig> {
  // Validate mutually exclusive pattern/every
  if (!input.pattern && !input.every) {
    throw new Error('Either pattern or every is required');
  }
  if (input.pattern && input.every) {
    throw new Error('pattern and every are mutually exclusive');
  }

  // 1. Insert into database (source of truth)
  const schedule = await this.db.schedule.create({
    data: {
      tenantId: input.tenantId ?? null,
      name: input.name,
      pattern: input.pattern,
      every: input.every,
      tz: input.tz ?? 'UTC',
      jobName: input.jobName,
      jobData: (input.jobData ?? {}) as any,
      jobOptions: (input.jobOptions ?? {}) as any,
      enabled: true,
    },
  });

  // 2. Sync to BullMQ (async — never roll back DB on BullMQ failure)
  await this.syncToBullMQ(schedule);
  return schedule as ScheduleConfig;
}

The internal syncToBullMQ method translates your database model into BullMQ's API:

private async syncToBullMQ(schedule: ScheduleConfig): Promise<void> {
  const schedulerId = buildSchedulerId(schedule.tenantId, schedule.name);

  const repeatOpts: Record<string, unknown> = {};
  if (schedule.pattern) repeatOpts.pattern = schedule.pattern;
  if (schedule.every) repeatOpts.every = schedule.every;
  if (schedule.tz) repeatOpts.tz = schedule.tz;
  if (schedule.startDate) repeatOpts.startDate = schedule.startDate;
  if (schedule.endDate) repeatOpts.endDate = schedule.endDate;
  if (schedule.limit) repeatOpts.limit = schedule.limit;
  if (schedule.immediately) repeatOpts.immediately = true;

  await this.queue.upsertJobScheduler(
    schedulerId,
    repeatOpts,
    {
      name: schedule.jobName,
      data: schedule.jobData ?? {},
      opts: { ...(schedule.jobOptions ?? {}) },
    },
  );
}

UPDATE with Optimistic Locking

Updates must handle concurrent modification. The version column prevents lost updates:

async update(id: string, input: UpdateScheduleInput, expectedVersion: number) {
  const existing = await this.db.schedule.findUnique({ where: { id } });
  if (!existing) throw new Error('Schedule not found');
  if (existing.version !== expectedVersion) {
    throw new Error('Schedule modified by another process — retry');
  }

  const schedule = await this.db.schedule.update({
    where: { id },
    data: {
      ...input,
      version: { increment: 1 },
      // ... date conversions, JSON casting
    },
  });

  if (schedule.enabled) {
    await this.syncToBullMQ(schedule);
  } else {
    await this.removeFromBullMQ(schedule);
  }

  return schedule as ScheduleConfig;
}

DELETE (Soft-Delete + Cleanup)

Deletion is a two-step process: soft-delete in the DB, then remove from BullMQ:

async delete(id: string): Promise<void> {
  const schedule = await this.db.schedule.update({
    where: { id },
    data: { deletedAt: new Date(), enabled: false },
  });
  await this.removeFromBullMQ(schedule);
}

private async removeFromBullMQ(schedule: ScheduleConfig): Promise<void> {
  const schedulerId = buildSchedulerId(schedule.tenantId, schedule.name);
  await this.queue.removeJobScheduler(schedulerId);
}

REST API Endpoints

Wire the service to a REST router for runtime schedule management:

// routes/schedules.ts (Express)
router.post('/', async (req, res) => {
  const schedule = await service.create(req.body);
  res.status(201).json(schedule);
});

router.put('/:id', async (req, res) => {
  const { version, ...input } = req.body;
  const schedule = await service.update(req.params.id, input, version);
  res.json(schedule);
});

router.delete('/:id', async (req, res) => {
  await service.delete(req.params.id);
  res.status(204).end();
});

router.get('/', async (req, res) => {
  const tenantId = req.query.tenantId as string | undefined;
  const schedules = await service.list(tenantId);
  res.json(schedules);
});

router.post('/:id/toggle', async (req, res) => {
  const existing = await service.getById(req.params.id);
  if (!existing) return res.status(404).json({ error: 'not found' });
  const updated = await service.update(req.params.id, { enabled: !existing.enabled }, existing.version);
  res.json(updated);
});

Boot-Time Sync — Loading Schedules on Application Start

Every time your application starts, it must load all active schedules from the database into BullMQ. This ensures the execution engine stays aligned with the source of truth.

// services/boot-sync.ts
export async function bootSyncSchedules(db: PrismaClient, queue: Queue) {
  const schedules = await db.schedule.findMany({
    where: { enabled: true, deletedAt: null },
  });

  const errors: Error[] = [];
  let synced = 0;

  // Sequential sync — important to avoid overloading Redis
  for (const schedule of schedules) {
    try {
      await syncToBullMQ(schedule);
      synced++;
    } catch (err) {
      errors.push(new Error(`Failed to sync schedule ${schedule.id}: ${(err as Error).message}`));
    }
  }

  return { synced, failed: errors.length, errors };
}

Why sequential, not concurrent? BullMQ's upsertJobScheduler uses Redis transactions internally (WATCH/MULTI/EXEC). Concurrent calls on the same scheduler ID can trigger optimistic lock failures. Sequential processing is predictable and fast enough — 1,000 schedulers complete in roughly 2–5 seconds, which is well within acceptable startup windows.

Boot sync must happen before workers start. If workers start first, they might process stale job data or miss newly created schedulers:

async function main() {
  const queue = createQueue();

  // 1. Sync all schedules before workers start processing
  const { synced, failed } = await bootSyncSchedules(prisma, queue);
  console.log(`Boot sync: ${synced} synced, ${failed} failed`);

  // 2. Now start workers — safe
  const worker = createWorker();
  await worker.waitUntilReady();

  // 3. Start HTTP server
  startServer(queue, worker);
}

Distributed Boot-Sync Lock

When running multiple application instances, only one should perform boot sync. Use Redis's SET NX to acquire an exclusive lock:

export async function acquireBootLock(redis: Redis, ttlMs = 30_000): Promise<boolean> {
  const result = await redis.set(
    'bullmq:boot-sync:lock',
    process.pid.toString(),
    'PX', ttlMs, 'NX'
  );
  return result === 'OK';
}

Runtime Sync — Handling Schedule Changes Without Restarting Workers

Boot sync handles startup. But what happens when a user changes a schedule at 3 PM while the system is running?

The solution is mutation-time sync: every CRUD operation on a schedule automatically syncs the change to BullMQ as part of the same request. The user's HTTP request completes only after the BullMQ sync succeeds (or you can return 202 Accepted and sync asynchronously).

Fire-and-Forget Pattern (202 Accepted)

For bulk operations or high-availability APIs where you don't want to block on Redis operations, decouple the sync:

async createAsync(input: CreateScheduleInput): Promise<{ id: string; status: 'accepted' }> {
  const schedule = await this.db.schedule.create({ data: { ... } });

  // Don't await — sync in the background
  this.syncToBullMQ(schedule as ScheduleConfig).catch(err => {
    console.error(`Async sync failed for schedule ${schedule.id}:`, err);
  });

  return { id: schedule.id, status: 'accepted' };
}

Change Data Capture with PostgreSQL LISTEN/NOTIFY

When multiple services or application instances can modify schedules, use PostgreSQL's built-in LISTEN/NOTIFY for real-time change propagation:

async function listenForScheduleChanges(queue: Queue) {
  const pg = new Client(process.env.DATABASE_URL);
  await pg.connect();
  await pg.query('LISTEN schedule_changes');

  pg.on('notification', async (msg) => {
    const payload = JSON.parse(msg.payload!);
    // { action: 'created' | 'updated' | 'deleted', id: string }
    const schedule = await prisma.schedule.findUnique({ where: { id: payload.id } });
    if (!schedule || schedule.deletedAt) {
      await queue.removeJobScheduler(schedule
        ? buildSchedulerId(schedule.tenantId, schedule.name)
        : payload.id);
    } else if (schedule.enabled) {
      await syncToBullMQ(schedule);
    } else {
      await queue.removeJobScheduler(buildSchedulerId(schedule.tenantId, schedule.name));
    }
  });
}

Periodic Reconciliation (Safety Net)

Despite all precautions, things can still go wrong — a Redis flush, a missed webhook, a manual key deletion. A periodic reconciliation job catches these edge cases:

export async function reconcileSchedules(service: ScheduleService, queue: Queue) {
  const dbSchedules = await service.list();
  const bullSchedulers = await queue.getJobSchedulers(0, -1); // fetch all

  const dbMap = new Map(dbSchedules.map(s => [buildSchedulerId(s.tenantId, s.name), s]));
  const bullMap = new Map(bullSchedulers.map(s => [s.id, s]));

  let added = 0, removed = 0;

  // Missing in BullMQ but present in DB → upsert
  for (const [id, schedule] of dbMap) {
    if (!schedule.enabled || schedule.deletedAt) continue;
    if (!bullMap.has(id)) {
      await service.syncToBullMQ(schedule);
      added++;
    }
  }

  // Present in BullMQ but missing from DB → orphan, remove
  for (const [id] of bullMap) {
    if (!dbMap.has(id)) {
      await queue.removeJobScheduler(id);
      removed++;
    }
  }

  return { added, removed };
}

Run this every 5–15 minutes on a lightweight cron, and you have three layers of resilience: boot sync, mutation sync, and reconciliation.


Multi-Tenant Schedule Isolation

If you're building a SaaS platform, different tenants' schedules must not interfere. Three patterns, in increasing order of isolation:

Pattern 1 — Scheduler ID Namespacing (Simplest)

Prefix every scheduler ID with the tenant identifier using the buildSchedulerId() helper:

  • tenant:org_abc123:daily-report
  • tenant:org_def456:daily-report

No BullMQ-level changes needed — this is purely a naming convention. All schedulers live in the same queue, but their IDs are globally unique.

Pattern 2 — Per-Tenant Queues (Strongest Isolation)

Each tenant gets their own BullMQ queue:

export class TenantQueueFactory {
  private queues = new Map<string, Queue>();

  getQueue(tenantId: string): Queue {
    if (!this.queues.has(tenantId)) {
      this.queues.set(tenantId, new Queue(`queue:${tenantId}`, {
        connection: this.connection,
      }));
    }
    return this.queues.get(tenantId)!;
  }
}

Pros: complete isolation, per-tenant rate limiting, independent scaling. Cons: more Redis memory, more connections.

Shared queue for efficiency, namespaced IDs for uniqueness, tenant ID in job data for tenant-aware processing:

const worker = new Worker('shared-queue', async (job) => {
  const tenantId = job.data.tenantId;
  await rateLimiter.check(tenantId);    // per-tenant rate limit
  await processTenantJob(tenantId, job); // tenant-specific logic
});

This gives you the operational simplicity of a single queue with the logical isolation of per-tenant processing.

Quota Enforcement

Prevent one tenant from creating thousands of schedules:

async function enforceQuota(tenantId: string, maxSchedules = 50): Promise<void> {
  const count = await prisma.schedule.count({
    where: { tenantId, deletedAt: null },
  });
  if (count >= maxSchedules) {
    throw new Error(`Tenant ${tenantId} has reached the maximum of ${maxSchedules} schedules`);
  }
}

Monitoring Dynamically Created Schedulers

Dynamic schedules are invisible unless you instrument them. BullMQ provides introspection APIs:

// List all schedulers
const schedulers = await queue.getJobSchedulers(0, -1);

// Inspect a specific scheduler
const detail = await queue.getJobScheduler('tenant:org_abc:daily-report');
console.log(detail.next);   // next run timestamp
console.log(detail.repeat); // repeat options
console.log(detail.tpl);    // job template

Building a Health Dashboard

A healthy scheduler is one whose next expected execution falls within a reasonable window. You can build a health status by enriching the BullMQ introspection data with database state:

export async function getSchedulerStatuses(queue: Queue) {
  const schedulers = await queue.getJobSchedulers(0, -1);

  return Promise.all(schedulers.map(async (s) => {
    const detail = await queue.getJobScheduler(s.id);
    return {
      id: s.id,
      name: s.id.replace(/^tenant:[^:]+:/, ''),
      tenantId: s.id.startsWith('tenant:') ? s.id.split(':')[1] : null,
      pattern: (detail?.repeat as any)?.pattern ?? 'interval',
      nextRun: detail?.next ? new Date(detail.next).getTime() : null,
      enabled: true,
      healthy: detail?.next != null,
    };
  }));
}

Alert on:

  • Schedulers missing from BullMQ but present in DB
  • Schedulers whose nextRun timestamp is far in the past (missed execution)
  • Schedulers producing jobs faster than workers can consume them (backlog growth)

Prometheus Metrics

Export these metrics for your observability stack:

# HELP bullmq_scheduler_active Active schedulers per queue
# TYPE bullmq_scheduler_active gauge
bullmq_scheduler_active{queue="shared-queue"} 142

# HELP bullmq_scheduler_next_run Timestamp of next scheduled run
# TYPE bullmq_scheduler_next_run gauge
bullmq_scheduler_next_run{scheduler="tenant:org_abc:daily-report"} 1719878400

# HELP bullmq_scheduler_sync_errors Schedule sync failures
# TYPE bullmq_scheduler_sync_errors counter
bullmq_scheduler_sync_errors{error_type="redis_connection"} 3

QueueHub's dashboard can display all your dynamically created schedulers via the getJobSchedulers() API, with per-tenant filtering and real-time health status.


Performance at Scale

BullMQ's Overhead Per Scheduler

Each BullMQ Job Scheduler consumes approximately 300–800 bytes in Redis (hash key + sorted set entry). At 10,000 schedulers, you're looking at roughly 3–8 MB of Redis memory — negligible for production Redis instances.

Boot-Sync Timing

  • Sequential sync: ~2–5 ms per scheduler
  • 1,000 schedulers: ~2–5 seconds
  • 10,000 schedulers: ~20–50 seconds

For larger deployments, use batched concurrency:

async function batchSync(schedules: ScheduleConfig[], batchSize = 100) {
  for (let i = 0; i < schedules.length; i += batchSize) {
    const batch = schedules.slice(i, i + batchSize);
    await Promise.allSettled(batch.map(s => syncToBullMQ(s)));
    if (i + batchSize < schedules.length) {
      await new Promise(r => setTimeout(r, 100)); // stagger
    }
  }
}

Worker Scaling

The number of schedulers does not directly affect worker load. A scheduler that runs once per day produces exactly one job per day, regardless of how many other schedulers exist. Scale your workers based on job throughput, not scheduler count.

Database Considerations

  • The (enabled, deletedAt) index keeps boot-sync queries fast even with millions of records
  • For multi-tenant SaaS, consider partitioning the schedules table by tenantId
  • Archive (soft-delete + move) schedules older than a retention period to keep the active set fast

When NOT to Use This Pattern

This pattern adds complexity (database, sync layer, reconciliation cron) in exchange for runtime flexibility. It's not the right choice for every scenario:

  • Fewer than 10 static schedules — Just use upsertJobScheduler directly in your code. No database needed.
  • Ephemeral environments — CI, staging, or developer sandboxes don't need persistent schedule storage. Use YAML config files with boot-time upsert.
  • Sub-second precision requirements — BullMQ's resolution is second-level; if you need microsecond scheduling, this isn't the right tool.
  • Schedules that change every few seconds — The DB → BullMQ sync overhead adds unnecessary latency. Consider direct API calls for rapidly changing schedules.

Conclusion

Managing BullMQ Job Schedulers from a database gives you the flexibility to support runtime schedule configuration without sacrificing reliability. The key takeaways:

  • Database as source of truth, BullMQ as execution engine — the cleanest separation of concerns
  • upsertJobScheduler is inherently idempotent — making runtime sync safe and repeatable
  • Three layers of resilience: boot-time sync, mutation-time sync, and periodic reconciliation
  • Multi-tenant isolation via scheduler ID namespacing or per-tenant queues
  • Monitor with getJobSchedulers() and health check heuristics tied to Prometheus metrics

Ready to see your dynamically managed schedules in action? QueueHub gives you real-time visibility into all your BullMQ schedulers — including dynamically created ones — across queues and backends. Sign up for free and start monitoring your schedules today.

Related Articles