---
title: "WordPress Job Queue: Run Background Jobs Reliably"
description: "A WordPress job queue moves slow work out of the request into a database-backed worker. How queue tables, claiming, retries and your library options actually work."
url: "https://wpwebhooks.org/blog/wordpress-job-queue/"
date: "2026-07-19"
---

# WordPress Job Queue: Run Background Jobs Reliably

**TL;DR:** A WordPress job queue is a persistent table of pending work that a background worker drains, so slow tasks never block the web request.

-   A producer _enqueues_ a job by inserting a pending row instead of doing the work inline.
-   A worker _claims_ a batch (a conditional UPDATE), runs each job, and marks it complete.
-   Failures are retried while attempts remain, then marked failed — a per-item audit trail WP-Cron cannot give you.

/ Definition

## What is a **job queue** in WordPress?

A job queue is a persistent list of work to be done later, drained by a background worker. Instead of running an expensive task inside the request that triggered it, you write a small row describing the task — the job — and return immediately. Something else picks that row up moments later and does the actual work. In WordPress that "something else" is almost always driven by a [WP-Cron](https://developer.wordpress.org/plugins/cron/) tick, and the list itself is a database table.

The value is in decoupling. The user who placed the order, or the admin who clicked "sync 5,000 products", gets an instant response; the heavy lifting happens out of band, at a pace the server can sustain. This is the concrete, table-level view of the broader idea covered in [WordPress async background processing](https://wpwebhooks.org/blog/wordpress-async-background-processing/) — here we focus on the queue itself.

FIG 01 — Producer, queue table and worker in a job queue

/ Why not WP-Cron

## Why not just use **WP-Cron** for background jobs?

WP-Cron is a scheduler, not a queue. It stores every event in a single serialised `cron` array inside `wp_options`, runs on page loads, keeps no history, and never retries a failure. That is perfectly good for "run this once tonight." It falls apart when you have thousands of discrete jobs that each need to be claimed exactly once, retried on error, and audited afterward — there is nowhere in a serialised option to track per-job status or attempt counts.

| Concern | Raw WP-Cron event | Job queue table |
| --- | --- | --- |
| Storage | One serialised wp\_options row | One indexed row per job |
| Claiming | None — no concept of ownership | Conditional UPDATE locks a batch |
| Retry | None | Attempt counter + backoff |
| History | Gone once it runs | Per-job status and log |
| Throughput control | All-or-nothing per tick | Batch size caps work per pass |

/ Schema

## What does a job queue look like in the database?

At its simplest, a queue is one table. Each row carries the payload, a status, an attempt count, and timestamps. The `status` and `available_at` columns are what the worker filters on; the `attempts` column is what turns a transient failure into a retry rather than a lost job.

| Column | Type | Purpose |
| --- | --- | --- |
| `id` | bigint | Primary key for the job. |
| `payload` | longtext | JSON describing the work (an order id, a batch offset, an endpoint). |
| `status` | varchar | `pending`, `processing`, `complete`, or `failed`. |
| `attempts` | int | Incremented each run; drives retry and give-up logic. |
| `available_at` | datetime | Earliest time the job may run — how you delay a retry. |
| `reserved_at` | datetime | When a worker claimed it; lets you reclaim stuck jobs. |

/ Claiming

## How does a worker claim and process jobs safely?

The hard part of any queue is making sure two concurrent workers never run the same job. The race-safe technique is a single conditional `UPDATE`: flip a batch of `pending` rows to `processing` and stamp them with a worker/claim marker, filtering on the current status so only one worker can win each row. Whatever rows that UPDATE actually changed are yours to process.

PHP — claim a batch, then process it

```
global $wpdb;
$claim = wp_generate_uuid4();

// Atomically reserve up to 10 due jobs for THIS worker
$wpdb->query( $wpdb->prepare(
    "UPDATE {$wpdb->prefix}my_jobs
     SET status = 'processing', claim = %s, reserved_at = %s
     WHERE status = 'pending' AND available_at <= %s
     ORDER BY id LIMIT 10",
    $claim, current_time( 'mysql' ), current_time( 'mysql' )
) );

// Only rows carrying our claim are ours to run
$jobs = $wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->prefix}my_jobs WHERE claim = %s", $claim
) );
```

This is exactly the pattern Action Scheduler formalises with its separate claims table — the claim marker is what guarantees single execution under concurrency.

> A job queue is correct when a job is claimed by exactly one worker and either completes or is retried — never silently dropped, never run twice. — the core queue invariant

/ Retries

## How should a queue handle retries and failures?

Wrap each job so a thrown exception does not kill the whole batch. On failure, increment `attempts`, and if it is still under the limit push the job back to `pending` with a future `available_at` — this is where [exponential backoff](https://wpwebhooks.org/blog/webhook-retry-policy-exponential-backoff/) belongs, spacing retries out as failures accumulate. Once `attempts` hits the ceiling, mark the row `failed` so it stops consuming worker time and shows up for a human to inspect. That "give up cleanly and keep the evidence" step is what separates a real queue from an infinite retry loop.

1.  **Run inside a try/catch.** One bad job must not abort the other nine in the batch.
2.  **On error, increment attempts.** If under the limit, reset status to `pending` and push `available_at` into the future (backoff).
3.  **At the limit, mark failed.** Stop retrying, keep the row and its last error for inspection.

/ Libraries

## What libraries give you a job queue in WordPress?

You rarely need to hand-roll all of this. Three common options cover most needs, and each is a variation on the same enqueue-claim-process loop.

| Option | What it gives you | Best for |
| --- | --- | --- |
| [Action Scheduler](https://actionscheduler.org/) | Custom tables, retries, groups, admin UI; bundled with WooCommerce | Most production sites; anything WooCommerce-adjacent |
| [WP Background Processing](https://github.com/deliciousbrains/wp-background-processing) | Lightweight batch processing over WP-Cron and the options table | Simple one-off bulk tasks, few dependencies |
| Custom `$wpdb` table | Total control over schema, claiming and retry | Unusual requirements the libraries do not model |

For the overwhelming majority of cases the answer is Action Scheduler — it already implements claiming, retries, and an audit trail correctly. Its internals are a useful reference for how a mature queue is structured; see the [Action Scheduler database schema](https://wpwebhooks.org/blog/action-scheduler-database-tables/) and the [PHP API reference](https://wpwebhooks.org/blog/action-scheduler-api-functions/).

/ When

## When do you need a queue vs a single scheduled event?

Reach for a queue when work is high-volume, must retry on failure, needs a per-item trail, or must not block the request. A single delayed task — one reminder email, one cleanup pass — is fine on a lone scheduled event and does not justify a table and a worker. The moment you have many independent jobs arriving in bursts, each of which can fail on its own, you want the queue.

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"WordPress Job Queue: Run Background Jobs Reliably","description":"A WordPress job queue moves slow work out of the request into a database-backed worker. How queue tables, claiming, retries and your library options actually work.","datePublished":"2026-07-19","dateModified":"2026-07-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org"},"url":"https://wpwebhooks.org/blog/wordpress-job-queue/","image":"https://wpwebhooks.org/og_image.jpg","keywords":["wordpress job queue","wordpress queue","wp queue","wordpress background jobs","wordpress queue table","wordpress job queue library"]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"WP Webhooks","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://wpwebhooks.org/blog/"},{"@type":"ListItem","position":3,"name":"WordPress Job Queue: Run Background Jobs Reliably","item":"https://wpwebhooks.org/blog/wordpress-job-queue/"}]}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"What is a job queue in WordPress?","acceptedAnswer":{"@type":"Answer","text":"A job queue is a persistent list of pending work — usually a database table — that a background worker drains over time. The web request enqueues a job and returns immediately; the slow work runs later, outside the request that triggered it."}},{"@type":"Question","name":"Why not just use WP-Cron for background jobs?","acceptedAnswer":{"@type":"Answer","text":"WP-Cron stores every event in one serialised wp_options row, runs on page loads, keeps no history, and never retries. That is fine for a nightly task but not for thousands of discrete jobs that each need claiming, retrying, and logging."}},{"@type":"Question","name":"How does a worker avoid running the same job twice?","acceptedAnswer":{"@type":"Answer","text":"By claiming. The worker flips a batch of pending rows to a processing/claimed state in a single conditional UPDATE, so any other worker that runs concurrently sees those rows as already taken and skips them."}},{"@type":"Question","name":"What libraries give you a job queue in WordPress?","acceptedAnswer":{"@type":"Answer","text":"Action Scheduler (database-backed, retries, admin UI, bundled with WooCommerce) is the most common; WP Background Processing by Delicious Brains is a lighter option; and some teams build a custom table with their own worker. Each is a variation on the same enqueue-claim-process loop."}},{"@type":"Question","name":"When do I need a queue instead of a single scheduled event?","acceptedAnswer":{"@type":"Answer","text":"When work is high-volume, must retry on failure, needs a per-item audit trail, or must not block the request. A one-off delayed task is fine on a single scheduled event; recurring bursts of many independent jobs want a queue."}}]}
```
