---
title: "Payload CMS autoRun vs Worker: Practical Queue Guide"
slug: "payload-cms-autorun-vs-worker-queue-guide"
published: "2026-05-17"
updated: "2026-05-26"
categories:
  - "Payload"
tags:
  - "Payload CMS autoRun"
  - "dedicated worker"
  - "Payload jobs"
  - "queue routing"
  - "generateMediaBlurPlaceholder"
  - "processOrderInventory"
  - "disableScheduling"
  - "shouldAutoRun"
  - "payload jobs:run"
  - "job architecture"
  - "autoRun vs worker"
  - "media backfill"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "payload cms"
  - "next.js"
  - "sharp"
  - "ffmpeg"
  - "docker compose"
status: "stable"
llm-purpose: "Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…"
llm-prereqs:
  - "Access to Payload CMS"
  - "Access to Next.js"
  - "Access to Sharp"
  - "Access to FFmpeg"
  - "Access to Docker Compose"
llm-outputs:
  - "Updated docker-compose configuration files for the new environment"
---

**Summary Triples**
- (autoRun, processes, only the queues explicitly configured to run in the app process)
- (dedicated worker (bin/worker or jobs:run), isolates, heavy or long-running jobs (media processing, inventory reconciliation, backfills))
- (queue, routes, jobs to the appropriate runner (autoRun or worker) by name)
- (payload.jobs.queue(), creates, a durable job document in the payload-jobs collection)
- (shouldAutoRun / per-queue config, enables, conditional in-process execution (tenant-aware or environment-aware))
- (disableScheduling, recommended for, worker instances that should not run scheduled/cron jobs)
- (hybrid architecture, keeps, UX fast by running light jobs in autoRun and offloading heavy work to workers)
- (durability and retries, rely on, Payload's persisted payload-jobs docs and idempotent task handlers)

### {GOAL}
Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…

### {PREREQS}
- Access to Payload CMS
- Access to Next.js
- Access to Sharp
- Access to FFmpeg
- Access to Docker Compose

### {STEPS}
1. Learn the core concepts
2. Inventory your queues and costs
3. Classify jobs by weight
4. Configure autoRun for light queues
5. Deploy dedicated workers for heavy jobs
6. Monitor, backpressure, and iterate

<!-- llm:goal="Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…" -->
<!-- llm:prereq="Access to Payload CMS" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Sharp" -->
<!-- llm:prereq="Access to FFmpeg" -->
<!-- llm:prereq="Access to Docker Compose" -->
<!-- llm:output="Updated docker-compose configuration files for the new environment" -->

# Payload CMS autoRun vs Worker: Practical Queue Guide
> Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…
Matija Žiberna · 2026-05-17

If you are building a Payload CMS app and wondering whether to reach for `autoRun` or a dedicated bin script worker, the more useful framing is not "which one" but "which one for which queue." `autoRun` is queue-selective — it does not run every Payload job, only jobs from the queues you explicitly configure it to process. That distinction unlocks a hybrid architecture where `autoRun` handles light work inside the app process, and a dedicated worker handles heavy work in isolation. This article explains how each runner works, what it costs, and how to split your jobs between them using real examples from a production multi-tenant marketplace build.

I went through this decision while designing the job architecture for a Farmica farm ordering platform — a system that handles media uploads, transactional emails, inventory processing, and order fan-out all within the same Payload app. Getting the queue routing right early made a meaningful difference in how the app behaved under load.

---

## The Mental Model You Need First

Payload separates four concepts that are easy to conflate.

A **task** is a function definition — the shape and logic of some work you want done. A **job** is a concrete queued instance of that task, stored as a document in the `payload-jobs` collection. A **queue** is a named lane that groups related jobs together — something like `emails`, `media`, or `inventory`. A **runner** is whatever picks jobs off a queue and executes them.

A job does not run because you defined a task. You queue it with `payload.jobs.queue()`, Payload writes a document to the database, and then a runner picks it up. That database persistence is what makes queues durable across restarts and what lets you route different job types to completely different runners.

The runner configuration is where `autoRun` and the dedicated bin script diverge.

---

## The Critical Thing autoRun Does Not Do

`autoRun` does not run every Payload job in the system. It runs jobs from the queues you give it. This is the most important thing to understand before designing your architecture.

If you configure `autoRun` like this:

```ts
// File: payload.config.ts
jobs: {
  autoRun: [
    {
      cron: '* * * * *',
      queue: 'emails',
      limit: 25,
      disableScheduling: true,
    },
  ],
},
```

then jobs queued to `media`, `inventory`, or `video` will sit in `payload-jobs` untouched until another runner processes those queues. `autoRun` will never touch them. Payload's own troubleshooting docs give exactly this example — if you queue to `critical` but `autoRun` only processes `default`, the job will not be picked up.

You can also run multiple `autoRun` entries in the same app process, each targeting a different queue on its own cron interval:

```ts
// File: payload.config.ts
jobs: {
  autoRun: [
    { cron: '* * * * *',   queue: 'emails',    limit: 25  },
    { cron: '*/5 * * * *', queue: 'logs',      limit: 100 },
    { cron: '*/10 * * * *',queue: 'reports',   limit: 10  },
  ],
},
```

Each entry is its own independent runner inside the same process. This is the routing mechanism — queue name is the contract between the producer and the runner.

---

## What autoRun Actually Does (and One Trap)

`autoRun` runs within the Next.js/Payload process. It polls for pending jobs on a cron interval and executes them synchronously within that process. That means it shares CPU, memory, database connections, and I/O with your app.

A few things the docs do not make obvious:

**`autoRun` is not instant.** A job queued at 12:00:10 with a `* * * * *` cron may not run until 12:01. For transactional email this is usually acceptable. For anything user-facing, it is not.

**`autoRun` handles scheduling by default.** Unless you set `disableScheduling: true`, it will also call `handleSchedules`, which can create jobs from `schedule` definitions on tasks. If you are running both `autoRun` and a bin script for the same queue, this can duplicate scheduled jobs. Set `disableScheduling: true` when you want `autoRun` to only process already-created jobs.

**`shouldAutoRun` is a kill switch, not a throttle.** The docs say that if `shouldAutoRun` returns `false`, the cron schedule is stopped — not skipped for one tick, stopped. Do not use it for dynamic backpressure like checking active job count. Use it only for stable environment gating:

```ts
// File: payload.config.ts
shouldAutoRun: async () => process.env.ENABLE_AUTORUN === 'true',
```

This is the correct use case: controlling which app replicas act as runners. On a multi-instance deployment, you set `ENABLE_AUTORUN=true` only on designated instances so that every web process is not competing to run jobs.

**`autoRun` must not run on serverless.** Payload's docs are explicit — it requires a long-running server. On Vercel, use the `/api/payload-jobs/run` endpoint triggered by Vercel Cron instead.

---

## What a Dedicated Worker Does

A dedicated worker is the Payload bin script running as a separate process:

```bash
pnpm payload jobs:run --cron "* * * * *" --queue media --limit 5
```

That process has no Next.js overhead, shares no resources with your web app, and can be deployed, restarted, and scaled entirely independently. You can containerize it with its own resource limits, install native dependencies like sharp or FFmpeg without polluting the main app bundle, and crash it without affecting API response times.

The Payload docs position the bin script as the recommended production approach for dedicated servers. `autoRun` is described as the simpler alternative — not the stronger isolation pattern.

---

## Real Job Cost Analysis: A Production Example

Here is what the queue breakdown actually looked like on a marketplace platform I built with Payload — a multi-tenant farm ordering system with media uploads, checkout flow, and transactional email.

### Heaviest (CPU and I/O)

**`media` queue → `generateMediaBlurPlaceholder`**

Downloads the full image from S3 or HTTP, runs sharp (resize to 10×10, blur, PNG), and writes the `blurDataURL` back to Payload. This fires on every new image upload. A `backfillMediaBlurPlaceholders` maintenance task can enqueue up to 500 blur jobs in one go. This was the main heavy queue in normal day-to-day operation and was an immediate candidate for a dedicated worker.

### Medium (DB and external APIs)

**`inventory` queue → `processOrderInventory`**

Runs the full inventory hook logic off the request path — reserve, release, or fulfill per line item, with status transitions. Cost scales with line item count. Each order update can run a line-item diff and a status transition inside the same job.

**`emails` queue → `sendTransactionalEmail`**

Resolves template and recipient data via several DB reads, runs a dedup check, then calls Brevo over HTTP. A single new order can queue two transactional emails plus a `sendOrderEmails` job simultaneously. Under a flash sale or bulk order scenario, this queue is noisier than it looks at rest.

### Heavy Only When Run Manually

**`default` queue → `generateProductSlugs` / `generateCollectionSlugs`**

Loads up to 1000 docs, then for each doc across three locales (sl, en, de): one `findByID` plus one `update`. This is a lot of database round-trips concentrated in a short window. It is maintenance-triggered and infrequent, but it should never run inside `autoRun` on a production replica.

**`default` queue → `migrateRuLocaleToDe`**

Raw SQL over many locale tables combined with OpenAI calls per Cyrillic field. One-off migration task, extremely heavy, always runs as a manual dedicated worker invocation.

### Light (Fine for autoRun)

| Queue / task | Why it is light |
|---|---|
| `logs` → `persistLog` | Single log row write |
| `promo` → `processOrderPromoUsage` | A few scoped reads plus one promo update |

### The Volume Cap to Watch

With `autoRun` configured at a limit of 50 jobs per minute across all queues, a large media backfill or an order spike can cause the `media` and `emails` queues to lag for several ticks. This is worth monitoring if you bulk-upload images or run a flash sale. The lag is not dangerous, but it is visible in user-facing flows that depend on `blurDataURL` being populated quickly.

---

## The Decision Framework

The queue name is the contract. The runner only processes the queue you assign to it. Heavy queues must never be included in an in-process `autoRun` config.

| Job type | Queue | Runner | Reason |
|---|---|---|---|
| Transactional email | `emails` | `autoRun` | Mostly network I/O, short, predictable |
| Blur placeholder generation | `media` | Dedicated worker | CPU and I/O, fires on every upload, can bulk-enqueue 500 at once |
| Inventory processing | `inventory` | Dedicated worker | DB-heavy, scales with line item count |
| Log persistence | `logs` | `autoRun` | Single write, negligible cost |
| Promo usage update | `promo` | `autoRun` | Small scoped reads, safe in-process |
| Slug generation | `default` | Manual dedicated invocation | Bulk DB round-trips, maintenance-only |
| One-off data migrations | `default` | Manual dedicated invocation | Extreme cost, never in autoRun |
| Video transcoding | `video` | Dedicated worker | CPU, memory, disk, long-running |

The practical split I arrived at:

```
autoRun inside app process:
  emails          — transactional, low volume
  logs            — single-write, negligible
  promo           — scoped reads, bounded

dedicated worker (media):
  media           — blur generation, every upload, backfill spikes

dedicated worker (inventory):
  inventory       — order processing off the request path

manual invocation only:
  default         — slug generation, migrations
```

---

## The Code

Queuing to the right queue at the call site:

```ts
// File: src/jobs/queue.ts

// Light — autoRun will pick this up
await payload.jobs.queue({
  task: 'sendTransactionalEmail',
  input: { to, templateId, variables },
  queue: 'emails',
})

// Light — autoRun will pick this up
await payload.jobs.queue({
  task: 'persistLog',
  input: { level: 'info', message, context },
  queue: 'logs',
})

// Heavy — only the dedicated media worker will pick this up
await payload.jobs.queue({
  task: 'generateMediaBlurPlaceholder',
  input: { mediaId, url },
  queue: 'media',
})

// Heavy — only the dedicated inventory worker will pick this up
await payload.jobs.queue({
  task: 'processOrderInventory',
  input: { orderId, lineItems, previousDoc },
  queue: 'inventory',
})
```

`autoRun` configured to handle only the light queues:

```ts
// File: payload.config.ts
jobs: {
  autoRun: [
    {
      cron: '* * * * *',
      queue: 'emails',
      limit: 25,
      disableScheduling: true,
    },
    {
      cron: '* * * * *',
      queue: 'logs',
      limit: 100,
      disableScheduling: true,
    },
    {
      cron: '* * * * *',
      queue: 'promo',
      limit: 50,
      disableScheduling: true,
    },
  ],
  shouldAutoRun: async () => process.env.ENABLE_AUTORUN === 'true',
},
```

Dedicated workers as separate Docker Compose services:

```yaml
# File: docker-compose.yml
services:
  app:
    command: pnpm start
    environment:
      ENABLE_AUTORUN: 'true'

  worker-media:
    command: pnpm payload jobs:run --cron "* * * * *" --queue media --limit 5
    deploy:
      resources:
        limits:
          cpus: "2"
          memory: 2G

  worker-inventory:
    command: pnpm payload jobs:run --cron "* * * * *" --queue inventory --limit 10
```

The `limit` on the media worker is intentionally low because each blur job calls sharp, and sharp is CPU and memory intensive. Five concurrent jobs per tick is enough throughput for normal upload volume while keeping the worker from saturating available memory. For backfill spikes, the jobs simply queue up across multiple ticks — the database persistence handles the backlog safely.

---

## FAQ

**Does autoRun pick up jobs from all queues automatically?**

No. `autoRun` only processes jobs from the queues you explicitly configure in each `autoRun` entry. A job queued to `media` with only an `emails` entry in `autoRun` will sit in the database until a runner targeting `media` picks it up. This is by design — queue name is the routing contract.

**What does `disableScheduling: true` actually do?**

It tells `autoRun` to skip the `handleSchedules` step, meaning it will only process jobs that are already in the queue rather than also creating new jobs from `schedule` definitions on tasks. Set it when you want predictable, run-only behavior and are handling schedule creation elsewhere.

**Can `shouldAutoRun` be used to throttle autoRun dynamically?**

Carefully — if `shouldAutoRun` returns `false`, the cron schedule stops rather than skipping a single tick. It is safe for environment-level gating like `process.env.ENABLE_AUTORUN === 'true'`, but risky for dynamic checks based on current load or active job count.

**What happens to jobs in heavy queues if I never set up a dedicated worker?**

They accumulate in `payload-jobs` with `waitingForWorker` status and never run. Payload does not automatically fall back to processing them inside `autoRun`. This is actually useful during development — you can queue jobs freely and only spin up the worker when you are ready to process them.

**Can I run autoRun locally and a dedicated worker in production?**

Yes. Use `shouldAutoRun` with an environment variable to gate the behavior, or simply omit `autoRun` from your production config entirely and run everything via bin script. For local development, `autoRun` on all queues is the fastest way to test the full job lifecycle without additional processes.

---

## Conclusion

`autoRun` and the dedicated bin script worker are not alternatives to choose between — they are complementary runners designed to handle different categories of work. `autoRun` handles light, I/O-bound jobs inside the app process with minimal infrastructure. The dedicated worker handles heavy, CPU-intensive, or failure-sensitive jobs in isolation.

The routing mechanism is the queue name. Every job specifies a queue, and every runner specifies which queue it processes. Keeping that discipline — light queues in `autoRun`, heavy queues in dedicated workers, maintenance queues as manual invocations — gives you the UX benefit where user actions return fast, and the operational benefit where a crashing worker cannot take down your app.

If you are designing a Payload jobs architecture and want to think through the queue routing for your specific workload, drop a question in the comments below.

Thanks,
Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS autoRun vs Worker: Practical Queue Guide\" cover?",
      "answer": "Payload CMS autoRun vs dedicated worker: route jobs by queue for faster UX and reliable processing. Read the decision framework, examples, and deployment…"
    }
  ]
}
```