MainWP uptime checks stuck on Last Check
By Philipp Kant 6 min read
MainWP uptime monitoring is a WordPress-based availability check that stores monitor state and heartbeat rows inside the MainWP Dashboard database. When it works, WP-Cron selects due monitors, MainWP checks the target URLs, writes a heartbeat, and moves the monitor’s Last Check time forward.
A failure mode worth knowing: the scheduler can run, the monitor can be selected, and the check can still never finish.
That is what happened on a Docker-hosted MainWP Dashboard behind Traefik BasicAuth. Manual Force Check worked. The automatic monitor stayed stuck on the old Last Check timestamp.
The final fix was one line:
add_filter('mainwp_fetch_uptime_disable_check_multi_exec', '__return_true');
We put it in a must-use plugin so MainWP uses the simple sequential cURL path instead of the default multi-cURL path.
WP-Cron was running, but the uptime check was not finishing
The first assumption was that WP-Cron was broken. It was not that simple.
The Coolify scheduled task ran every five minutes and exited cleanly. DISABLE_WP_CRON was set to true, which is fine when an external scheduler triggers WordPress cron. The relevant MainWP events existed:
mainwp_cronuptimemonitoringcheck_action now 1 minute
mainwp_cronupdatescheck_action now 1 minute
mainwp_cron_perform_general_process now 1 minute
But wp cron event run --due-now initially ran zero events even though events were due. The first real problem was an out-of-order WP-Cron array.
WordPress assumes the internal cron array is sorted by timestamp. If the first key is in the future, the ready-job scan can stop before later overdue events are seen.
The repair was:
wp eval '$c=_get_cron_array(); ksort($c, SORT_NUMERIC); _set_cron_array($c);' --path=/var/www/html --allow-root
After that, WP-Cron moved again. MainWP events were rescheduled. But uptime still did not write a fresh heartbeat.
BasicAuth looked guilty, but it was not the cause
The dashboard was protected by Traefik BasicAuth. That was worth testing because WordPress self-requests to the dashboard URL returned 401:
wp_remote_post(admin_url("admin-ajax.php")) -> HTTP 401
wp_remote_get(site_url("wp-cron.php")) -> HTTP 401
A temporary must-use plugin added BasicAuth credentials to WordPress HTTP API requests for the dashboard’s own domain. That made normal WordPress self-requests work:
admin-ajax -> 400
wp-cron -> 200
But MainWP uptime still did not finish. Then BasicAuth was disabled at the actual Traefik router and verified from outside:
curl https://mainwp.example.com/ -> HTTP 200
The official MainWP cron files still produced the same stalled state:
php /var/www/html/wp-content/plugins/mainwp/cron/checkstatuschilds.php
php /var/www/html/wp-content/plugins/mainwp/cron/generalschedules.php
Result:
heartbeats: 1
lasttime_check: 11:43:42
dts_auto_monitoring_start: current
dts_auto_monitoring_time: 11:43:43
BasicAuth was a real self-connect problem for generic WordPress HTTP requests. It was not the cause of the built-in uptime failure.
The database showed that MainWP started the run
The key table was wp_mainwp_monitors.
The monitor row kept changing one field:
dts_auto_monitoring_start: current timestamp
But the completion fields stayed old:
lasttime_check: 2026-06-09 11:43:42
dts_auto_monitoring_time: 2026-06-09 11:43:43
heartbeats_total: 1
That means the scheduler found the monitor and marked it as started. It did not reach the response handler that writes dts_auto_monitoring_time, lasttime_check, and a heartbeat row.
This distinction matters. It rules out most generic WP-Cron advice. The job fired. The monitor was selected. The check path failed after that.
The failing path was MainWP’s multi-cURL runner
The relevant MainWP code path is:
mainwp_cron_uptime_monitoring_check_action()
-> MainWP_Uptime_Monitoring_Schedule::cron_uptime_check()
-> MainWP_Uptime_Monitoring_Connect::check_monitors()
cron_uptime_check() sets dts_auto_monitoring_start. Then check_monitors() chooses between two runners:
$disable_multi_exec = apply_filters(
'mainwp_fetch_uptime_disable_check_multi_exec',
false
);
if (! $disable_multi_exec && curl_multi_exec is available) {
$this->check_multi_monitors($monitors, $glo_settings);
} else {
$this->check_uptime_monitors($monitors, $glo_settings);
}
On this system, MainWP took the default check_multi_monitors() path through curl_multi_exec. That path did not reach the handler that updates the monitor and writes the heartbeat.
Forcing the sequential path fixed it immediately.
The fix is a tiny must-use plugin
Create this file:
/var/www/html/wp-content/mu-plugins/dsm-mainwp-uptime-no-multi.php
With this content:
<?php
/**
* Force MainWP built-in uptime monitoring to use sequential cURL
* instead of curl_multi_exec.
*/
add_filter('mainwp_fetch_uptime_disable_check_multi_exec', '__return_true');
In Docker:
WP=$(docker ps --format '{{.Names}}' | grep -i wordpress | head -1)
cat > /tmp/dsm-mainwp-uptime-no-multi.php <<'PHP'
<?php
/**
* Force MainWP built-in uptime monitoring to use sequential cURL
* instead of curl_multi_exec.
*/
add_filter('mainwp_fetch_uptime_disable_check_multi_exec', '__return_true');
PHP
docker exec "$WP" mkdir -p /var/www/html/wp-content/mu-plugins
docker cp /tmp/dsm-mainwp-uptime-no-multi.php "$WP":/var/www/html/wp-content/mu-plugins/dsm-mainwp-uptime-no-multi.php
docker exec "$WP" chown www-data:www-data /var/www/html/wp-content/mu-plugins/dsm-mainwp-uptime-no-multi.php
rm /tmp/dsm-mainwp-uptime-no-multi.php
A must-use plugin loads automatically. There is no activation step in wp-admin. It also survives redeploys if wp-content is on a persistent volume.
The verification is a heartbeat, not a green cron log
A successful scheduled task is not enough. Verify the MainWP heartbeat table and monitor timestamps.
Run the MainWP uptime cron directly:
docker exec "$WP" php /var/www/html/wp-content/plugins/mainwp/cron/checkstatuschilds.php
Then inspect the monitor state:
docker exec "$WP" php /tmp/wp-cli.phar eval 'global $wpdb;
echo "heartbeats: ".$wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}mainwp_monitor_heartbeat")."\n";
$r=$wpdb->get_row("SELECT lasttime_check,dts_auto_monitoring_start,dts_auto_monitoring_time FROM {$wpdb->prefix}mainwp_monitors WHERE monitor_id=1",ARRAY_A);
foreach($r as $k=>$v){ echo "$k: ".($v>1000000000?gmdate("H:i:s",$v):$v)."\n"; }
echo "now: ".gmdate("H:i:s")."\n";' --path=/var/www/html --allow-root
After the fix, the output changed from a stuck single heartbeat to fresh timestamps:
heartbeats: 3
lasttime_check: 16:14:03
dts_auto_monitoring_start: 16:14:03
dts_auto_monitoring_time: 16:14:04
That is the real proof. The monitor completed and wrote a new heartbeat.
The tradeoff is parallelism for reliability
The fix disables MainWP’s parallel uptime runner. Checks become sequential.
That tradeoff is acceptable for a small pilot. It is also often acceptable for modest fleets if the timeout and interval are set sanely. For a larger fleet, test the numbers:
| Fleet size | What to watch |
|---|---|
| 1-10 sites | Sequential checks are usually fine. Keep the fix and observe. |
| 10-50 sites | Lower timeout, avoid one-minute intervals if sites are slow. |
| 50-100+ sites | Measure total run time, tune timeout, consider splitting monitors or using an external uptime service if needed. |
The point is not that sequential cURL is better. The point is that a correct slower check beats a parallel check that never writes a heartbeat.
The debugging checklist
When MainWP uptime monitoring is stuck on Last Check, check the system in this order:
- Confirm a manual Force Check works.
- Confirm the scheduled task runs.
- Confirm the MainWP uptime event exists.
- Check for a stuck
doing_crontransient. - Check whether the WP-Cron array is sorted.
- Inspect
wp_mainwp_monitorsfordts_auto_monitoring_startanddts_auto_monitoring_time. - If start updates but time does not, test the no-multi filter.
The diagnostic signal is simple:
dts_auto_monitoring_start changes
dts_auto_monitoring_time stays old
heartbeats do not increase
That points past the scheduler and into the fetch/response path.
FAQ
Is this a MainWP bug?
It is a compatibility failure in the MainWP multi-cURL path on this runtime. I would not claim it affects every MainWP install. In this Docker/Coolify/PHP/cURL environment, the default multi-cURL path did not complete. The sequential path did.
Is BasicAuth the reason MainWP uptime monitoring failed?
BasicAuth was not the reason in this case. It did block generic WordPress self-requests, but the built-in uptime check still failed when BasicAuth was temporarily disabled and verified as off.
Does disabling multi-cURL make MainWP uptime monitoring unsafe?
No. It changes execution strategy, not monitoring logic. MainWP still checks the same monitors and writes the same heartbeat data. The checks run sequentially instead of in parallel.
Should every MainWP install use this filter?
No. Use it when the built-in uptime monitor starts runs but does not finish them. If the default multi-cURL path works in your environment, keep it.
Should large fleets use built-in uptime monitoring?
Built-in uptime can work, but large fleets need timing tests. If sequential checks take too long, use MainWP’s Advanced Uptime Monitor with an external provider or a dedicated uptime system.
If this kind of failure shows up in a business-critical WordPress fleet, the fix is rarely one setting. You need to trace the scheduler, the database state, the network path, and the runtime path. That is the work we do.