Your WordPress site (or a specific endpoint) returns HTTP 429, with a body that says "Too Many Requests". You see it in the browser, in Query Monitor's HTTP API Calls panel, in wp-content/debug.log, or in the nginx access log. Sometimes a single page returns 429. Sometimes only logged-in admins see it. Sometimes it is only visible as a failing outbound request to woocommerce.com or another third-party API. Sometimes it clears on its own after 10 minutes and comes back an hour later.
What a 429 actually means
RFC 6585 §4 defines 429 as: "The 429 status code indicates that the user has sent too many requests in a given amount of time ('rate limiting')." The spec is intentionally short. It does not say who the user is, what the limit was, or when to try again. It only tells you that some layer in the conversation has counted requests from somebody and decided to stop answering for a while.
Three details from RFC 6585 matter for diagnosis:
Retry-Afteris optional. The response "MAY include a Retry-After header indicating how long to wait". Not SHOULD, not MUST. You cannot assume every 429 carries a retry hint, and if yours does not, the server is still spec-compliant.- The body SHOULD explain the condition. The RFC recommends (but does not require) that the body tells the client what the limit was. In practice, generic "429 Too Many Requests" pages give you nothing useful, and you have to go to the logs.
- 429 responses MUST NOT be cached. If you see a 429 that persists across multiple browsers and never clears for one visitor, the problem is not a stale CDN cache, it is a live rule.
The definition matters because it tells you what a 429 is not. A 429 is not a crash. It is not a DDoS symptom. It is not the server "being slow". It is a deliberate, healthy refusal. Whoever sent the 429 had enough capacity left to parse the request, count it, and answer politely. The job of diagnosis is to find out which layer said no, and whether the traffic it was counting was your visitors, your own cron, or an integration calling out from your site.
A 429 is not the same as the other overload-related errors in this category, and the distinction matters:
- 429 Too Many Requests: a rate limit has fired. Deliberate, scoped, and usually targeted at one source. This article.
- 503 Service Unavailable: the server itself is overloaded or in maintenance. Undirected, affects everybody. Note that a misconfigured nginx
limit_reqrule returns 503 by default, not 429, which is why some of "your 429s" may actually be showing up as 503s. - 504 Gateway Timeout: the upstream was reachable but took too long. Nothing to do with rate limits.
- 500 Internal Server Error: the application itself errored.
Common causes, ordered by likelihood
The tricky part of a WordPress 429 is that five different layers can produce one, and they look roughly identical from the browser. Here they are in the order I actually see them in the wild.
1. Your site is rate-limiting itself via its own outbound API calls
This is the single most common WordPress 429 in 2026, and almost nobody expects it. The mechanism is simple and self-inflicted:
- A plugin schedules a recurring background job (via Action Scheduler or
wp_schedule_event). - The job makes an outbound HTTP request to an external API.
- The external API rate-limits per outbound IP.
- Your job fails, Action Scheduler retries it with exponential backoff.
- On shared or managed hosts that use a shared NAT egress IP, other sites on the same IP are also calling the same API, and the combined traffic trips the external rate limit.
- Your site's error log fills with 429s from a URL your visitors are never hitting.
The textbook example is the WooCommerce.com Helper endpoint at https://woocommerce.com/wp-json/helper/1.0/update-check, which checks your paid WooCommerce extensions for updates and license validity. There is a documented real case on the WordPress.org support forums where a Cloudways customer saw continuous 429s against that exact URL. The root cause was Google Listings & Ads Action Scheduler jobs retrying repeatedly, each retry triggering a license check, and the WooCommerce.com rate limit on the shared Cloudways outbound IP kept rejecting them. The 429 was not Cloudways, not Cloudflare, not the origin server, and not malicious. It was WooCommerce.com correctly rate-limiting an outbound IP that was hitting them too often.
The same pattern shows up with Google APIs (Google Listings & Ads, Google Analytics plugin integrations), Mailchimp sync plugins, automatic.css license checks, and any plugin that polls an external service on a schedule. If you see 429 errors in the PHP error log or Query Monitor's HTTP API tab, and the URL is on a third-party domain, you are almost certainly in this cause.
2. A rate limit in front of wp-login.php is doing its job
Rate-limiting login POSTs is expected behavior and the 429 here is usually correct. Wordfence throttles requests per IP by default, All In One Security has a similar login lockout, and many managed hosts add their own nginx limit_req rule on /wp-login.php that returns 429 after a configurable burst. If your 429 is limited to /wp-login.php (or /xmlrpc.php, which is the same attack surface), the limit is firing on purpose and you should leave it alone unless it is locking out real users. When it locks out a real user, you unban the IP and keep the rule.
The common misconfiguration here is nginx rate limits that return 503 instead of 429. nginx's limit_req module defaults to limit_req_status 503, so a lot of 503s that appear to fire on login are actually a login rate limit with the wrong status code. Changing limit_req_status 503 to limit_req_status 429 in the server block is the right fix, because 429 is the semantically correct status for "you are making too many requests".
3. The WordPress Heartbeat API or wp-cron is hitting an origin rate limit
The Heartbeat API sends POST requests to /wp-admin/admin-ajax.php every 15 seconds on post edit pages and every 60 seconds on the dashboard. These requests bypass page caches because they are POSTs. On shared hosts with a hard per-account request-per-second cap, 5 concurrent admin users can produce enough admin-ajax traffic to trigger the host's own 429. Similarly, WP-Cron fires on every page load by default, and a site with concurrent traffic and a long queue can hammer wp-cron.php often enough to trip a server-level cap.
Both are fixable, but the fix is different: you slow the Heartbeat (via the heartbeat_settings filter or an optimization plugin) and you replace pseudo-cron with a real server cron. I describe the cron half in depth in my article on WP-Cron not working.
4. Cloudflare (or another CDN edge) is applying a rate-limiting rule
Cloudflare does not rate-limit WordPress sites by default. A 429 from Cloudflare means someone (probably you, or a security guide you followed) created a Cloudflare rate limiting rule in the dashboard and it is firing. Common cases: a /wp-json/* rule that was too tight for your own REST integrations, a /wp-login.php rule that blocks your monitor, or a site-wide rule that was copied from a blog post without adjusting the threshold.
Cloudflare 429s look identical to origin 429s in the browser. The only reliable way to tell them apart is the Cloudflare analytics dashboard (Security > Events, filtered on "Action: block" and "Service: Rate limiting") or the server access log. The presence of a CF-Ray header in the response does not identify the source. CF-Ray is added to every Cloudflare-proxied response, whether the 429 came from Cloudflare or from the origin behind it.
5. The host's WAF or a security plugin is returning 429 from its own rules
Wordfence throttles generic requests at 240 requests per minute per IP by default and crawler requests at 120 page views per minute. WP Engine and SiteGround do not publish their thresholds but will return 429 when you exceed "the maximum number of requests to a page within the last second". WordPress VIP publishes explicit numbers: 10 requests per second for crawlers at the edge, 10 XML-RPC requests per 30 seconds per IP, and 5 failed logins per 5 minutes. If your 429 comes from one of these platforms and correlates with a specific IP or behavior pattern, the host is doing exactly what you pay it to do, and the fix is either to whitelist the source or to slow down the caller.
A special case: if the 429 only fires on /wp-json/wc/store/* endpoints on a WooCommerce site, check whether WooCommerce Store API rate limiting has been enabled in your configuration. Introduced in WooCommerce 7.2 (December 2022), it is disabled by default but can be enabled via the woocommerce_store_api_rate_limit_options filter. When enabled, the default is 25 POST requests per 10 seconds per IP, and the response includes RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Retry-After headers so you can see the exact limit in the browser DevTools network tab.
Diagnose which cause applies
These checks are non-destructive. Run them in order before you change anything.
Check 1: determine whether the 429 is incoming or outgoing.
If you see the 429 in your browser when visiting the site, it is incoming (causes 2 to 5). If you see it in wp-content/debug.log, in Query Monitor's HTTP API Calls tab, or in a plugin's error report, and the URL points to an external domain like woocommerce.com or googleapis.com, it is outgoing (cause 1).
To light up outbound 429s in logs, temporarily enable WordPress debug logging in wp-config.php:
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
Then reproduce or wait for the error and read wp-content/debug.log. An outbound 429 looks like this in Action Scheduler's log:
action failed: WP_Error Object ( [errors] => Array (
[http_429] => Array ( [0] => You are being rate limited. )
) )
You will know it worked when: you can state with certainty whether the 429 is a response your visitors get from your site, or a response your site gets from somebody else's server. These are completely different problems.
After you identify the cause, set WP_DEBUG back to false so debug output stays out of production.
Check 2: read the access log (for incoming 429s).
Open the access log viewer in your hosting control panel (cPanel: Metrics > Raw Access, Plesk: Logs, or the equivalent in your provider's dashboard) and look for lines containing a 429 status code. What you are looking for is the request URL that triggers the 429. Note which URL appears most often.
If you have SSH access:
grep ' 429 ' /var/log/nginx/access.log \
| awk '{print $7}' \
| sort \
| uniq -c \
| sort -rn \
| head -20
Whether you use the panel or the command line, the top URL tells you where to look next. If the top URL is /wp-login.php, you are in cause 2 (login rate limit). If it is /wp-admin/admin-ajax.php, you are in cause 3 (Heartbeat). If it is /wp-cron.php, you are in cause 3 (WP-Cron). If it is /wp-json/wc/store/* on a WooCommerce site, you are probably in cause 5 (Store API rate limiting enabled). If it is a generic frontend URL hit by one specific IP, you are in cause 2 or 4 (a rate-limit rule firing against a real client).
You will know it worked when: you have the exact request URI and client IP associated with the 429, and you have decided whether the limit is protecting you from something real or locking you out of your own site.
Check 3: distinguish Cloudflare from origin (for Cloudflare-proxied sites).
Do not rely on the CF-Ray response header to decide whether Cloudflare or your origin sent the 429. That header is on every Cloudflare-proxied response and cannot tell you which layer generated the status code. Instead, use the Cloudflare dashboard: Security > Events, filter on "Action: block" and "Service: Rate limiting". A Cloudflare-blocked 429 will appear here with the specific rule name. If the Events page shows no matching rate-limit event for the time of the error, Cloudflare did not generate the 429 and the origin did.
Alternatively, bypass Cloudflare entirely with curl --resolve:
curl --resolve yoursite.nl:443:1.2.3.4 -I https://yoursite.nl/the-failing-url/
Replace 1.2.3.4 with your actual origin IP. If the direct request to the origin returns 200 and the proxied request through Cloudflare returns 429, the 429 is from Cloudflare and the fix is in the Cloudflare dashboard. If both return 429, the 429 is from your origin and Cloudflare is just passing it through.
You will know it worked when: you can state "Cloudflare rule X is firing" or "the origin itself is returning 429". Anything vaguer is a guess.
Check 4: look for runaway Action Scheduler jobs (for outbound 429s).
In the WordPress admin, go to Tools > Scheduled Actions (this is Action Scheduler's UI, present if WooCommerce or any plugin using Action Scheduler is installed). Filter by status "Failed" and sort by date. A site with a runaway outbound job will show dozens to hundreds of failed actions with the same hook name, each failing within minutes of the last retry. The hook name tells you which plugin is the culprit: gla/jobs/update_products is Google Listings & Ads, wc_helper_subscriptions_refresh is the WooCommerce.com Helper, and so on.
For a deeper look:
wp action-scheduler list --status=failed --per-page=50
You will know it worked when: you can name the exact plugin and the exact hook that is retrying the outbound request hundreds of times.
Solutions, per cause
Cause 1 fix: your site is hammering an external API
This is the most common and the most satisfying to fix, because the 429 disappears as soon as you stop the self-inflicted traffic.
-
Identify the runaway plugin. Use Check 4 above to find the hook name with the highest failed count.
-
Disable the plugin (or the specific feature) temporarily to confirm it is the source. The failed action count in Scheduled Actions should stop climbing within 15 minutes.
-
Delete the backlog of failed actions so Action Scheduler stops retrying them. In the WordPress admin: Tools > Scheduled Actions, filter "Failed", bulk-select, delete. Or via WP-CLI:
wp action-scheduler clean --status=failed -
Disable WP-Cron's pseudo-cron and replace it with a real server cron, so jobs run on a controlled schedule instead of on every page view. Open
wp-config.phpthrough your hosting panel's file manager (or download it via SFTP) and add this line above the "That's all, stop editing!" comment:define( 'DISABLE_WP_CRON', true );Then set up a cron job that calls
wp-cron.phpevery 15 minutes. In most hosting panels (cPanel: Cron Jobs, Plesk: Scheduled Tasks), create a new cron with the schedule*/15 * * * *and the command:wget -q -O - "https://yoursite.nl/wp-cron.php?doing_wp_cron" >/dev/null 2>&1WP-CLI alternative (if you have SSH access): the WP-CLI equivalent is cleaner because it does not go through the web stack at all:
*/15 * * * * cd /var/www/yoursite.nl && wp cron event run --due-now >/dev/null 2>&1The full reasoning for this switch is in my article on WP-Cron not working.
-
Re-enable the plugin and monitor. If the 429s come back, the underlying integration is genuinely trying to push more traffic than the external API allows, and the fix is either on the plugin's side (reduce polling frequency, batch requests) or on the hosting side (switch to a host with a dedicated outbound IP, so you are not sharing the external rate limit with strangers).
You will know it worked when: the Scheduled Actions "Failed" count stops growing, the PHP error log no longer contains outbound 429 entries, and the integration actually works again (products sync, licenses refresh, stats update).
Cause 2 fix: a login rate limit is firing
If the 429 is on /wp-login.php and the source IP belongs to a brute-force attacker, leave it alone. The rate limit is correct, 429 is the right response, and unbanning the attacker would make your site less safe.
If the 429 is locking out a real user (you, your client, your monitoring service):
-
In Wordfence: go to Wordfence > Tools > Live Traffic to see the blocked request, then Wordfence > Firewall > Blocking to remove the block. To prevent recurrence, add the IP to Wordfence's Whitelisted Services or configure Rate Limiting at Wordfence > Firewall > All Firewall Options > Rate Limiting with a less aggressive threshold.
-
If you have SSH access and the rate limit is in an nginx
limit_reqrule: edit the server block to raise the rate or burst, and make sure the response code is 429, not 503:limit_req_zone $binary_remote_addr zone=wp_login:10m rate=5r/m; location = /wp-login.php { limit_req zone=wp_login burst=10 nodelay; limit_req_status 429; # ... rest of location ... }rate=5r/mallows 5 requests per minute per IP,burst=10queues up to 10 extra requests without delay, andlimit_req_status 429makes the correct status code come back instead of the default 503. -
At a managed host (WP Engine, Kinsta, Cloudways, SiteGround): the rule is not in your control. Open a support ticket with the exact source IP, the timestamp, and the URL, and ask them to whitelist the IP.
You will know it worked when: the IP that was being locked out can successfully log in (or hit the target URL) without being throttled, and attackers are still being blocked. Test both.
Cause 3 fix: Heartbeat or WP-Cron is hitting a host rate limit
Heartbeat is worth slowing down, not killing. A complete Heartbeat disable breaks autosave and post locking, which is a bigger problem than the 429.
The easiest way to slow it down is through a performance plugin you can configure in wp-admin. Perfmatters, WP Rocket, and LiteSpeed Cache all have a "Heartbeat control" section where you can raise the interval from 15 seconds to 60 seconds, or disable Heartbeat on the frontend specifically (which is safe, because frontend Heartbeat is only used by a handful of plugins).
If you prefer code: extend the interval via the heartbeat_settings filter in a small mu-plugin or a code snippet:
add_filter( 'heartbeat_settings', function ( $settings ) {
$settings['interval'] = 60; // seconds, up from the default 15 on edit pages
return $settings;
} );
For WP-Cron, apply the fix in Cause 1, step 4: define DISABLE_WP_CRON and add a real server cron. This caps the cron rate to a schedule you control.
You will know it worked when: the nginx access log shows a sharp drop in POST requests to /wp-admin/admin-ajax.php and /wp-cron.php, the host no longer returns 429s for those URLs, and autosave still works in the post editor (this is the check that tells you Heartbeat is still alive).
Cause 4 fix: a Cloudflare rate-limiting rule is too tight
Open the Cloudflare dashboard, go to Security > WAF > Rate limiting rules, and find the rule whose name matches the failing URL. The symptoms tell you how to adjust it:
- If the rule is firing on a
/wp-json/*endpoint and blocking your own integrations: either add your integration's source IP to an allowlist (via Security > WAF > Tools > IP Access Rules, create an "Allow" entry), or raise the threshold on the rule itself. Most Cloudflare rate limit rules default to "10 requests per 10 seconds"; for a busy integration, "60 requests per 10 seconds" is more realistic. - If the rule is firing on
/wp-login.phpand blocking your monitor: whitelist the monitor's known IP ranges. - If the rule was copied from a security article and you do not remember why it exists: delete it, and if 429s return, add a narrower rule that targets only the specific attack pattern you are trying to stop.
Cloudflare changes apply within a minute. Verify with a direct test from the blocked source.
You will know it worked when: the client that was getting 429s from Cloudflare now gets 200s, the Cloudflare Security > Events log stops showing matching rate-limit events for that client, and the rule is still blocking whatever you originally designed it to block.
Cause 5 fix: a host WAF or security plugin rule is firing
Unlike Cloudflare, managed-host rate limits at the edge are rarely directly editable. The fix path is:
-
Identify the exact rule from the host's dashboard or support team. WP Engine, Kinsta, and SiteGround have explicit tools for this; Cloudways does not, and you will need a ticket.
-
If the 429 is from Wordfence or All In One Security: the thresholds are in the plugin's settings (Wordfence > Firewall > All Firewall Options > Rate Limiting, All In One Security > Brute Force > Rename Login Page / Cookie Based Brute Force Prevention). Raise the threshold, or add an exclusion for the legitimate traffic.
-
If the 429 is from WooCommerce Store API rate limiting you did not mean to enable: disable it via the filter:
add_filter( 'woocommerce_store_api_rate_limit_options', function ( $options ) { $options['enabled'] = false; return $options; } );Or keep it enabled but raise the threshold (the same filter accepts a
limitandsecondsoption). -
If the 429 is on an endpoint that genuinely sees too much traffic for your hosting plan: cache more aggressively at the edge so the origin sees fewer requests, or move the offending workload to a dedicated host.
You will know it worked when: the specific URL and client pattern that was triggering the 429 no longer does, and the rule is still catching abusive traffic.
When to escalate
If the steps above do not pinpoint the cause within 30 minutes, hand the incident off to your host or developer. Have these ready in the first message, because they are the first thing any engineer will ask for:
- The exact URL that returns 429, or the exact outbound URL your site is calling when the 429 appears.
- The direction: incoming (a visitor gets 429 from your site) or outgoing (your site gets 429 from somebody else's server).
- The timestamp of the failing request, including timezone, and whether it is reproducible or only sporadic.
- Your stack: hosting tier (shared, VPS, managed, containers), server software (nginx or Apache with version), PHP version, WordPress version, WooCommerce version if installed.
- Is the site behind Cloudflare or another CDN? If yes, does bypassing it with
curl --resolvestill produce the 429? - The matching line from the nginx access log, including the request URI, status, and source IP.
- The matching line from the nginx error log, especially anything containing
limiting requests,limiting connections, or a Wordfence / AIOS / plugin identifier. - For outbound 429s: the hook name and failed count from Scheduled Actions, and which plugin owns that hook.
- The list of plugins active on the site, especially anything installed or updated in the last 48 hours.
- Whether the failing request has a
Retry-Afterheader (remember that a missingRetry-Afteris spec-compliant and does not mean the 429 is invalid).
Sending all of that in the first message saves a round trip and routes the ticket straight to the right engineer instead of bouncing between teams.
How to prevent it from coming back
A persistent 429 on a healthy WordPress site is almost always a sizing or calibration problem, not a security incident. Four habits keep it rare:
- Do not run pseudo-cron on a busy site. Set
DISABLE_WP_CRONinwp-config.phpand add a real cron job through your hosting panel's cron scheduler (or via WP-CLI if you have SSH access) on a 15-minute interval. Pseudo-cron was designed for shared hosts that have no real cron, and on any site where traffic is higher than jobs, it triggers too often. This is the single biggest reason I see self-inflicted 429s disappear. - Watch the Action Scheduler backlog. If Tools > Scheduled Actions > Failed has more than a handful of entries for the same hook, something is retrying the same broken call hundreds of times and you are either hitting an external rate limit or a broken integration. A healthy site has a Failed column in single digits, flushed regularly.
- Tune Heartbeat to your editorial workflow, not to the default. The default 15-second interval on edit pages was sized for WordPress 2.x and single-author blogs. On a multi-author site with 5 concurrent admins, that is 20 POSTs per second from the logged-in admin team alone, which some shared hosts will rate-limit. Raise the interval to 60 seconds and leave autosave working.
- Know which rate-limit rules exist. Cloudflare rate-limiting rules, nginx
limit_reqblocks, Wordfence settings, and host WAF rules are all silent until they fire. When you copy a rule from a blog post or follow a security hardening checklist, write down what it does and under what conditions, so that next month when it blocks a real user you know where to look. A rate-limit rule you cannot find is worse than no rule at all.
If you get all four right, the only 429s you will see are the ones you actually want: attackers being correctly throttled at the login, and bad bots being correctly throttled at the REST API. Everything else is the system telling you something in your own configuration is producing more traffic than it should, and that is a signal worth following back to its source.