Upload the zip to WordPress like any other plugin
Event-loop job queue for WordPress. Replaces WP-Cron's poll-on-visit model and system cron's once-per-minute limitation with a long-running Workerman process that executes every WP Cron event and Action Scheduler action at its exact scheduled time — zero polling, zero delay.
- Multisite operators running dozens or hundreds of sites where missed cron events and overlapping runners are a constant problem.
- Hosting providers who need predictable, observable background processing without per-site cron entries.
- Sites with heavy Action Scheduler workloads (WooCommerce Subscriptions, background imports, bulk email) that need parallel execution with per-job timeouts.
- Anyone who needs precise scheduling — if a job is scheduled for 14:32:07, it runs at 14:32:07, not whenever the next visitor arrives or the next minute ticks over.
| WP-Cron | The Perfect WP Cron |
|---|---|
| Triggers on page visits — low-traffic sites miss schedules | Triggers at exact scheduled time via event-loop timer |
| Adds latency to a visitor's request | Runs in a separate process — zero impact on web requests |
| Single-threaded — one job at a time | Configurable parallel workers and concurrency |
| No timeout protection | Per-job SIGALRM timeout stops runaway jobs |
| No visibility into what ran or failed | Admin dashboard + per-job log table with duration and errors |
System Cron (* * * * *) |
The Perfect WP Cron |
|---|---|
| Minimum 1-minute granularity | Sub-second precision via Workerman timers |
| Polls the database every minute even if nothing is due | Socket notification — the worker knows instantly when a new job is scheduled |
| One WP bootstrap per cron run | Batches jobs by site — multiple jobs share one WP bootstrap |
| Separate cron entry per site (multisite) | Single process handles all sites in the network |
| No built-in concurrency | Configurable worker count and max concurrent subprocesses |
| No automatic restart | Uptime + memory watchdog, designed for systemd auto-restart |
- Requires CLI/SSH access. You need to run a long-lived PHP process, typically via systemd. Shared hosting without shell access won't work.
- Linux only. Workerman requires
pcntl_forkandpcntl_signal, which are not available on Windows or macOS in production. - Workerman dependency. Adds ~200KB to your vendor directory. The process must be managed (started, monitored, restarted) outside of WordPress.
- More complex than default cron. There's a process to monitor. If it stops unexpectedly and systemd isn't configured, jobs won't run until someone notices.
- Socket communication. The web server's PHP process must be able to write to the Unix socket. File permissions matter.
- PHP 8.1+
pcntlextension (standard on Linux, verify withphp -m | grep pcntl)- Linux (Workerman uses
pcntl_fork) - WordPress 6.0+
- Composer
Add the repository and require the package:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/Ultimate-Multisite/the-perfect-wp-cron.git"
}
],
"require": {
"ultimate-multisite/the-perfect-wp-cron": "dev-main"
}
}Then run:
composer update ultimate-multisite/the-perfect-wp-cronThe plugin installs to wp-content/plugins/the-perfect-wp-cron/ (or web/app/plugins/ on Bedrock).
Clone into your plugins directory and install the Workerman dependency:
cd wp-content/plugins
git clone https://github.com/Ultimate-Multisite/the-perfect-wp-cron.git
cd the-perfect-wp-cron
composer install --no-devActivate the plugin in wp-admin (or network-activate on multisite).
Every setting can be configured via PHP constant (in wp-config.php) or environment variable. Constants take priority over env vars. All settings have sensible defaults — zero configuration is required to get started.
| Constant / Env Var | Default | Description |
|---|---|---|
QUEUE_WORKER_SOCKET_PATH |
/tmp/the-perfect-wp-cron.sock |
Unix socket path |
QUEUE_WORKER_RUNTIME_DIR |
worker script directory | Writable directory for Workerman PID, status and internal log files; required when the plugin tree is read-only |
QUEUE_WORKER_COUNT |
2 |
Number of worker processes (Workerman forks) |
QUEUE_WORKER_MAX_CONCURRENT |
1 |
Max concurrent subprocesses per worker |
QUEUE_WORKER_MAX_BATCH_SIZE |
50 |
Max jobs per subprocess batch |
QUEUE_WORKER_JOB_TIMEOUT |
300 |
Per-job timeout in seconds (SIGALRM) |
QUEUE_WORKER_BATCH_TIMEOUT |
3600 |
Subprocess timeout in seconds (safety net) |
QUEUE_WORKER_RESCAN_INTERVAL |
60 |
Seconds between database rescans |
QUEUE_WORKER_SCHEDULING_HORIZON |
3600 |
Only keep timers for jobs due within this many seconds; never shorter than the rescan interval |
QUEUE_WORKER_SCAN_TIMEOUT |
300 |
Full-network scanner subprocess timeout in seconds |
QUEUE_WORKER_EXCLUDED_ISOLATED_NETWORK_IDS |
empty | Comma-separated isolated network IDs owned by dedicated workers |
QUEUE_WORKER_EXCLUDED_ISOLATED_NETWORKS_FILE |
empty | JSON array of dedicated network IDs, or an inventory object with a networks object keyed by ID; re-read before each scan and fail-closed if invalid |
QUEUE_WORKER_BYPASS_CRON_HOOKS |
empty | Additional comma-separated WP-Cron hooks for the worker to ignore |
QUEUE_WORKER_MANAGED_CRON_HOOKS |
empty | Comma-separated default-bypassed hooks that this worker should manage |
QUEUE_WORKER_MEMORY_LIMIT |
200 |
Memory limit in MB before draining and recycling the event-loop child |
QUEUE_WORKER_UPTIME_LIMIT |
3600 |
Uptime in seconds before draining and recycling the event-loop child |
QUEUE_WORKER_LOG_FILE |
auto-detect | Path to log for admin viewer |
QUEUE_WORKER_LOG_RETENTION |
7 |
Days to keep job log entries |
DOMAIN_CURRENT_SITE |
localhost |
Primary domain for WP bootstrap in worker |
WP_ROOT_PATH |
auto-detect | Path to directory containing wp-load.php |
Example wp-config.php:
define('QUEUE_WORKER_SOCKET_PATH', '/run/the-perfect-wp-cron.sock');
define('QUEUE_WORKER_COUNT', 4);
define('QUEUE_WORKER_JOB_TIMEOUT', 600);
define('QUEUE_WORKER_LOG_FILE', '/var/log/the-perfect-wp-cron.log');# Foreground (for debugging)
php wp-content/plugins/the-perfect-wp-cron/bin/worker.php start
# Daemonized
php wp-content/plugins/the-perfect-wp-cron/bin/worker.php start -d
# Stop / Restart / Status
php wp-content/plugins/the-perfect-wp-cron/bin/worker.php stop
php wp-content/plugins/the-perfect-wp-cron/bin/worker.php restart
php wp-content/plugins/the-perfect-wp-cron/bin/worker.php statusBedrock: replace wp-content/plugins with web/app/plugins.
Create /etc/systemd/system/the-perfect-wp-cron.service:
[Unit]
Description=WordPress Queue Worker
After=network.target mariadb.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com/current
ExecStart=/usr/bin/php web/app/plugins/the-perfect-wp-cron/bin/worker.php start
Restart=always
RestartSec=5
KillSignal=SIGQUIT
KillMode=mixed
ExecReload=/bin/kill -USR2 $MAINPID
TimeoutStopSec=3700s
TimeoutAbortSec=120s
RuntimeMaxSec=infinity
WatchdogSec=120s
NotifyAccess=all
StandardOutput=append:/var/log/the-perfect-wp-cron.log
StandardError=append:/var/log/the-perfect-wp-cron-error.log
MemoryMax=1G
Environment=WP_ENV=production
Environment=QUEUE_WORKER_COUNT=4
[Install]
WantedBy=multi-user.targetsudo systemctl enable the-perfect-wp-cron
sudo systemctl start the-perfect-wp-cronwp queue status # Show worker PID, uptime, memory, pending/running jobs
wp queue populate # Rescan — send all pending jobs to the worker
wp queue restart # Drain and recycle the contacted event-loop childThe bridge schedules qw_cleanup_action_scheduler every five minutes. It uses
the native cleaner API for one pass of at most 100 completed and 100 canceled
actions older than the effective action_scheduler_retention_period (31 days
by default). It does not delete pending or failed actions, reset claims, or run
another AS queue runner. Cleaner status filters may narrow the two terminal
statuses but cannot expand them. A non-positive retention period fails closed.
An independently scheduled action_scheduler_run_actions_cleanup_hook with a
registered callback takes precedence. An orphaned event without a callback, or
a callback with no event, does not suppress this fallback. qw_cleanup_job_log
is separate maintenance for worker logs.
Large existing AS tables will drain gradually; do not increase the batch size or bulk-delete historical records without reviewing database load and retention requirements. Before rollout, check the effective retention filters and terminal row counts in each target runtime. Verify a bounded decrease afterwards while pending and failed rows remain untouched. The PHP regression suite does not replace an isolated WordPress/AS integration canary before production rollout.
Memory/uptime limits and wp queue restart stop accepting new work, cancel only
in-memory timers, and wait for active job and scanner subprocesses. Polling and
cron lease renewal continue. Existing per-job, batch, and scan timeouts still
apply. Pending jobs remain in WordPress/AS and are rediscovered; already-claimed
but unstarted jobs may wait for the existing five-minute claim lease and rescan.
wp queue status remains available and displays the draining reason.
Workerman respawns the drained child; its master PID need not change. With more
than one child, wp queue restart affects only the child contacted by the socket,
not the whole pool.
External stop/reload requires the matching service policy. Use
KillSignal=SIGQUIT, KillMode=mixed, and graceful SIGUSR2 reloads as above.
Workerman forwards the signal only to event-loop children, whose stop callbacks
keep polling executors until completion or their existing timeout. Mixed kill
mode prevents systemd from signaling every executor at once. Allow the batch
timeout plus margin in TimeoutStopSec (3700s for the default 3600s batch limit).
Hard OOM kills, ungraceful signals, or an exhausted stop budget cannot drain jobs.
Systemd RuntimeMaxSec measures the master's absolute lifetime, not progress;
child recycling never resets it. With PHP's sockets extension installed, the
scan coordinator sends WATCHDOG=1 every 30 seconds through NOTIFY_SOCKET.
Use WatchdogSec=120s and NotifyAccess=all before disabling the absolute master
deadline. Other children cannot mask a stalled coordinator with heartbeats.
This measures coordinator liveness, not callback success or individual pool
health: continue monitoring action outcomes, backlog age, and job/scan timeouts.
Without NOTIFY_SOCKET, notification is a no-op. Do not enable the service
watchdog on older plugin code or PHP without sockets support.
The stop path is tested with a real Workerman 5.2.2 master receiving SIGQUIT during an active executor. The executor completes before the master exits. The cleaner and failure reporting were also exercised against isolated WordPress 7.1.1 and Action Scheduler 3.9.3 SQL tables.
After activating the plugin, a Queue Worker page appears under:
- Network Admin > Settings (multisite)
- Tools (single site)
The dashboard shows:
- Worker Status — running/stopped, PID, uptime, memory, currently executing jobs. Auto-refreshes every 10 seconds.
- Per-Site Resource Usage — which sites consume the most CPU time over the last 24 hours.
- Job History — searchable, filterable, sortable log of every executed job with status, duration, and error messages.
- Recent Log Entries — tail of the worker log.
WordPress Request Worker Process (Workerman)
+---------------------------+ +------------------------------------+
| Cron_Interceptor | | Event loop (libevent/select) |
| hooks schedule_event |------->| Unix socket listener |
| | socket | Timer per job (exact timestamp) |
| Action_Scheduler_Bridge | | Periodic DB rescan (safety net) |
| hooks stored_action |------->| Memory + uptime watchdog |
+---------------------------+ +------------------------------------+
|
Timer triggers at scheduled time
|
Claim job (INSERT IGNORE lock)
|
Batch by site_id
|
Acquire per-site WP-Cron execution lease
|
+------------------------------+
| Subprocess: execute-job.php |
| Bootstrap WP for site |
| For each job in batch: |
| SIGALRM timeout guard |
| Run hook / AS action |
| Log result to qw_job_log |
+------------------------------+
Flow:
- WordPress schedules a cron event or Action Scheduler action.
- The plugin intercepts the schedule call and sends a JSON payload to the worker via Unix socket.
- The worker sets a Workerman timer for the job's exact timestamp.
- When the timer triggers, the worker atomically claims the job via
INSERT IGNOREinto a lock table (prevents duplicate execution across workers). - Claimed jobs are batched by
site_idand flushed to a subprocess every second. WP-Cron batches acquire a shared per-site lease so separate worker processes cannot update one site's cron option concurrently. - The subprocess (
execute-job.php) bootstraps WordPress for the target site's domain, executes each job with a per-job SIGALRM timeout, logs results to theqw_job_logtable, and exits. - The worker polls subprocesses for completion, refreshes active site leases, and logs batch results.
- A periodic database rescan catches any jobs that arrived before the worker started or bypassed socket notification.
Worker won't start — "Address already in use"
A leftover socket exists. The worker tries to clean it up automatically, but if another process holds it: rm /tmp/the-perfect-wp-cron.sock (or your configured path).
Jobs aren't executing
- Check
wp queue status— is the worker running? - Check
wp queue populate— does it find pending jobs? - Check the worker log for errors.
- Verify the web server user can write to the socket path.
"Could not find wp-load.php"
Set the WP_ROOT_PATH environment variable to the directory containing wp-load.php (for standard WP) or web/wp/wp-load.php's parent (Bedrock auto-detected).
Socket permission denied
The worker creates the socket with mode 0660. Ensure the web server user (www-data) and the worker process user are in the same group, or configure the socket path to a directory both can access.
Per-job timeout stops a legitimate long-running job
Increase QUEUE_WORKER_JOB_TIMEOUT (default 300 seconds). For specific hooks that need more time, consider breaking the work into smaller chunks.
High memory usage / frequent restarts
The watchdog drains and recycles event-loop children when memory exceeds QUEUE_WORKER_MEMORY_LIMIT (default 200 MB) or uptime exceeds QUEUE_WORKER_UPTIME_LIMIT (default 3600 seconds). Check the draining reason and active subprocesses before interpreting this as a failure. A regular systemd RuntimeMaxSec expiration is a separate absolute master-lifetime policy, not proof of a stuck loop.
GPL-2.0-or-later. See LICENSE.