WP Webhooks / Blog / Architecture
Article · Architecture

Designing a Reliable Job Queue in WordPress

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.

8 min 2026-07-19
#Architecture#Queues

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 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 — here we focus on the queue itself.

The producer to worker flow of a WordPress job queueA producer enqueues a job by inserting a pending row into a queue table instead of doing the slow work inside the web request. A worker, triggered on a schedule, claims a batch of pending rows by flipping their status to processing so no other worker takes them, then runs each job. A successful job is marked complete; a failed job is retried while attempts remain and marked failed once the attempt limit is exhausted.

success

error

yes

no

producer
enqueue( job, payload )

queue table
status = pending

worker claims a batch
status = processing

run the job

status = complete

attempts < max?

status = failed

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.

ConcernRaw WP-Cron eventJob queue table
StorageOne serialised wp_options rowOne indexed row per job
ClaimingNone — no concept of ownershipConditional UPDATE locks a batch
RetryNoneAttempt counter + backoff
HistoryGone once it runsPer-job status and log
Throughput controlAll-or-nothing per tickBatch 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.

ColumnTypePurpose
idbigintPrimary key for the job.
payloadlongtextJSON describing the work (an order id, a batch offset, an endpoint).
statusvarcharpending, processing, complete, or failed.
attemptsintIncremented each run; drives retry and give-up logic.
available_atdatetimeEarliest time the job may run — how you delay a retry.
reserved_atdatetimeWhen 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 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.

OptionWhat it gives youBest for
Action SchedulerCustom tables, retries, groups, admin UI; bundled with WooCommerceMost production sites; anything WooCommerce-adjacent
WP Background ProcessingLightweight batch processing over WP-Cron and the options tableSimple one-off bulk tasks, few dependencies
Custom $wpdb tableTotal control over schema, claiming and retryUnusual 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 and the PHP API reference.

/ 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.

FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

What is a job queue in WordPress? +
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.
Why not just use WP-Cron for background jobs? +
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.
How does a worker avoid running the same job twice? +
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.
What libraries give you a job queue in WordPress? +
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.
When do I need a queue instead of a single scheduled event? +
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.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate