How to Fix “Missed Schedule” Posts and WP-Cron Not Running in WordPress
Scheduled posts show “Missed schedule” and backups or emails never run. Find out why WP-Cron is not firing, replace it with a real system cron and clear stuck events.
Site Health flags a failed loopback request or a REST API error. Learn what the test does, test it from the server with curl, and fix DNS, firewall, auth and plugin causes.
Tools > Site Health lists a critical issue: “Your site could not complete a loopback request”, or “The REST API encountered an error”. Expand it and you see a detail line such as “Error: cURL error 28: Operation timed out after 10001 milliseconds with 0 bytes received (http_request_failed)”, “cURL error 6: Could not resolve host”, or “REST API Response: (401) Unauthorized”. Sometimes the warning is milder: “The REST API encountered an unexpected result” or a loopback that “returned an unexpected http status code”.
Both tests ask one question: can your server call its own website over the network and get a normal answer? When it cannot, nothing looks broken to visitors, but scheduled tasks, the code editors and some background jobs quietly stop working. This guide is for site owners, designers and developers who want to know why the server cannot reach itself, and how to fix it at the right layer.
A loopback request is an HTTP request that WordPress, running in PHP on your server, sends to your own site’s public URL. It leaves PHP, looks up your domain in DNS (or the server’s hosts file), connects to whatever IP that returns, negotiates HTTPS, and arrives back at your web server as if it came from a visitor. A second PHP worker then handles it.
Here is what the two Site Health tests actually send, based on the WordPress core code:
| Test | Request | Passes when |
|---|---|---|
| Loopback | POST to wp-cron.php, with your browser’s cookies, a 10 second timeout, and basic auth credentials if PHP can see them |
HTTP 200 comes back |
| REST API | GET to /wp-json/wp/v2/types/post?context=edit, with your cookies, a REST nonce and the same timeout |
HTTP 200 comes back with JSON describing the post type, including capabilities |
A transport failure (DNS, connection refused, timeout, TLS) is reported as critical, with a cURL error. A response with the wrong status code is reported as a recommendation, with the code. Both tests turn certificate verification off by default (through the https_local_ssl_verify filter, which defaults to false), so a self-signed certificate on staging is usually not the cause unless some code switches that filter on.
If your problem is the block editor saying “Updating failed. The response is not a valid JSON response.”, that is a different symptom with its own causes, and our guide to that block editor error covers it. This guide is about the server-to-itself path.
You need wp-admin access and SSH access to the web server; loopback problems can only be tested properly from the machine that makes the request. On shared hosting without SSH, you can still work through Steps 1, 4, 5 and 6, and send the rest to your host. Nothing in the diagnosis changes your site. Back up any file before you edit it (.htaccess, an Nginx config, /etc/hosts).
How to run the commands in this guide. Server commands run in an SSH terminal on the web server, from your site folder, the one that contains wp-config.php (often public_html). WP-CLI is called wp on most servers; some hosts name it wp-cli, so swap the name if wp is not found. Commands marked “your computer” use curl: macOS and Linux have it built in, and Windows 10 and 11 include curl.exe in PowerShell, or you can use WSL.
The detail line under the Site Health item narrows the cause before you run anything.
| Detail | Meaning | Go to |
|---|---|---|
| cURL error 6: Could not resolve host | The server cannot look up its own domain | Step 3 |
| cURL error 7: Failed to connect … Connection refused | The IP it resolved to refuses the connection, often a NAT or firewall issue | Step 3 |
| cURL error 28: Operation timed out | Packets go nowhere (firewall, NAT hairpin), or the request reached PHP and got stuck (sessions, busy workers) | Steps 3 and 6 |
| cURL error 35 or 60 | 35 is a failed TLS handshake (protocol or cipher mismatch); 60 is a certificate that failed verification, possible only if verification was switched on | Advanced |
| 401 Unauthorized | Password protection (basic auth) or a plugin requiring login | Step 4 |
| 403 Forbidden | A WAF, ModSecurity, a security plugin or Cloudflare blocked the request | Step 5 |
| 404 Not Found (REST) | The REST route is not reachable: permalinks or rewrite rules, or a plugin disabling the REST API | Step 5 |
| 500 or 503 | The request reached WordPress and something crashed or a maintenance mode answered | Step 6 |
Run the same kind of request yourself, from the server, so you can watch it.
curl -I https://example.com
curl -sS -o /dev/null -w "%{http_code} %{remote_ip} %{time_total}s\n" -X POST https://example.com/wp-cron.php
getent hosts example.com
Run these over SSH on the web server. Replace example.com with the hostname in your WordPress Address (Settings > General), exactly, including www. if it has one.
curl -I sends a HEAD request and prints the response headers. Good output starts with HTTP/2 200 or HTTP/1.1 200. A 401, 403 or 503 first line is your answer.-X POST sends a POST, -o /dev/null discards the body, and -w prints the status code, the IP curl actually connected to and the total time. Good: 200, your server’s IP, well under a second. Bad: a Cloudflare IP with 403, an old server’s IP, or a long hang ending in a timeout.getent hosts shows the IP your server resolves the name to, the same way PHP will, including any entry in /etc/hosts. Compare it with your server’s real IP from the hosting panel.Then test through WordPress’s own HTTP layer, which uses the same code path as Site Health:
wp eval '$r = wp_remote_post( site_url( "wp-cron.php" ), array( "timeout" => 10 ) ); echo is_wp_error( $r ) ? $r->get_error_message() : wp_remote_retrieve_response_code( $r ); echo "\n";'
Run this over SSH from the site folder. There are no placeholders: site_url() fills in your own address. It prints either the HTTP status code or the transport error message. Good output is 200. Anything else matches a row in the Step 1 table. This request does not carry your browser’s cookies, so if it returns 200 while Site Health fails, look at sessions and login-dependent plugins in Step 6.
curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" --resolve example.com:443:LOCAL_IP https://example.com/wp-cron.php -X POST
Run this over SSH on the web server. LOCAL_IP is 127.0.0.1 if the web server listens on all addresses, or the server’s private or public IP if it listens on a specific one (shown in your panel or with ip addr). --resolve tells curl to connect to that IP for example.com on port 443 without asking DNS, while still sending the right hostname for HTTPS and virtual hosting.
If this returns 200 quickly but the plain request in Step 2 failed, the web server is fine and the problem is getting there: the name resolves to an address the server cannot reach from inside, or to something in between that blocks it.
/etc/hosts on the new server sometimes still maps the domain to the old server’s IP, often left over from testing before DNS switched. getent hosts reveals it.LOCAL_IP example.com www.example.com
This is a line you add to /etc/hosts on the web server, as root. LOCAL_IP is the address that worked with --resolve above. List every hostname the site uses. This makes the server’s own requests go straight to itself, skipping NAT, Cloudflare and outside DNS, while visitors are unaffected. Good: Step 2 now returns 200 and Site Health passes on a rerun. Remember the entry exists: if you move the site later, remove it, or it becomes the stale entry described above. On shared hosting you cannot edit this file; send your findings to your host.
Password-protecting a staging site with HTTP basic auth is a good habit, and the most common reason for a 401 loopback. WordPress forwards the credentials on its loopback only when PHP can see them, and on many PHP-FPM setups it cannot. The clean fix is to let the server’s own IP through without a password while everyone else still needs one.
AuthType Basic
AuthName "Staging"
AuthUserFile HTPASSWD_PATH
<RequireAny>
Require ip SERVER_IP
Require valid-user
</RequireAny>
This goes in the .htaccess file in your site folder, replacing your existing basic auth block, above # BEGIN WordPress. HTPASSWD_PATH is the full path to your existing password file, and SERVER_IP is the IP the loopback connects from, usually the server’s own public IP (the remote_ip from Step 2, if there is no proxy in between). RequireAny lets a request in if either rule matches. Good: Step 2 returns 200 and a browser elsewhere still gets a password prompt.
satisfy any;
allow SERVER_IP;
allow 127.0.0.1;
deny all;
auth_basic "Staging";
auth_basic_user_file HTPASSWD_PATH;
These lines go in the site’s server { } block, in a file you edit on the server. satisfy any means a request passes if it matches either the IP allow list or a valid password. Check with sudo nginx -t and apply with sudo systemctl reload nginx. If the site is behind Cloudflare, the loopback arrives from a Cloudflare IP, which is one more reason to add the hosts file entry from Step 3.
Password protection added by a plugin or a panel’s directory privacy feature behaves the same way; look for an IP allow option there.
A 403, or a challenge page instead of a normal answer, means something decided the server’s own request looked suspicious. A POST to wp-cron.php with no referrer, from a data center IP, is exactly what many rules are built to stop.
wp-cron.php or /wp-json/. Note the rule ID and ask your host to exclude it for your server’s IP, or exclude it yourself on a VPS./wp-json/ itself returns 404, save Settings > Permalinks once to rebuild rewrite rules, and check that .htaccess (Apache) or try_files (Nginx) sends unknown paths to index.php. Our 404 on every page guide covers rewrite rules in detail.If Site Health also reports “An active PHP session was detected”, fix that first. A plugin that calls session_start() locks your session file for as long as your page is loading. The Site Health tests send your cookies, including the session cookie, so the loopback waits for a lock held by the very page that is waiting for the loopback. Nothing moves until the 10 second timeout, and you see cURL error 28. The fix belongs in the plugin, which should close its session as soon as it has read it; until then, deactivate it or replace it.
The Health Check & Troubleshooting plugin’s Troubleshooting Mode disables all plugins and switches to a default theme for your session only, while visitors keep seeing the normal site. Enable it, rerun the Site Health tests, then re-enable plugins one by one until the failure returns. On staging, wp plugin deactivate --all followed by reactivating in batches does the same job faster.
The loopback needs a second PHP worker while the first one waits for it. If every worker is busy, or the pool allows only one or two, the loopback queues until it times out. This shows up as intermittent cURL error 28 under load. Our 503 and PHP workers guide explains how to check and raise the worker limit.
When the cause is still unclear, watch the full exchange from the server with verbose curl:
curl -sv -o /dev/null -X POST https://example.com/wp-cron.php 2>&1 | grep -E "^\* (Trying|Connected|SSL|Server certificate)|^< HTTP|^< server|^< cf-ray|subject:|issuer:"
Run this over SSH on the web server, with your domain in place of example.com. -v prints the connection steps (lines starting with *) and the response headers (lines starting with <); 2>&1 sends them through the pipe, and grep -E keeps the interesting ones. Read it in order: “Trying” shows the IP chosen; “Connected” proves the TCP connection worked; the certificate subject and issuer show which certificate answered (a different site’s certificate means you hit the wrong virtual host or server); the HTTP line gives the status; server: cloudflare and a cf-ray header mean the request went out through Cloudflare. If it stops after “Trying” with no “Connected”, the problem is the network path (Step 3).
Next, check whether any code turned certificate verification on for local requests, which is the only way a self-signed or mismatched certificate breaks these tests:
wp eval 'var_dump( apply_filters( "https_local_ssl_verify", false, site_url() ) );'
Run this over SSH from the site folder. It prints bool(false) in a default install. bool(true) means a plugin, theme or must-use plugin has enabled verification, and the loopback now fails with cURL error 60 if the certificate is self-signed, expired or for another name. Either install a valid certificate (Let’s Encrypt works on staging subdomains too) or find and remove the filter with grep -rn "https_local_ssl_verify" wp-content. The OpenSSL and curl cheat sheets cover reading certificate chains in more depth.
Finally, to see what WordPress’s HTTP layer receives in full, extend the wp eval test from Step 2 to print the response headers with print_r( wp_remote_retrieve_headers( $r ) ). A header such as cf-mitigated: challenge, a www-authenticate header or a security plugin’s own header usually names the blocker outright.
Open a ticket if the server cannot resolve or reach its own domain, if you cannot edit the hosts file, firewall or ModSecurity rules, or if loopbacks time out only under load. Send:
getent hosts, with the UTC time you ran them;--resolve test;Our diagnostic tools show how your domain resolves and responds from the outside, which is a useful comparison with what the server sees from inside. If the site is down for visitors too, start at the Emergency Room.
Did this fix it?
Still stuck? Start again from what you can see in the Emergency Room.
Still stuck?
Print a free one-page checklist to keep, or open the Emergency Room if the symptom does not match.
Get the free checklistsKeep reading
Search the field manual
Or jump straight to a symptom
Press / to search from any page, Esc to close