Why our WordPress server hit 100% CPU (and it wasn’t an attack)

/ Development 
Nov 9, 2026 | 8 min read
AI-generated illustration — A woman in a black dress stands in a concrete room, hand to her chin, thinking.
AI-generated image
Olga LescuyerFrontend Developer,SEO specialist

Our WordPress server kept hitting its CPU limit. The cause was our own website, not an attack. Crawlers kept visiting mainly one page, our blog list. Our site rebuilt this page partially on every visit, and each rebuild sent new queries to WordPress.

It started with a message from our host. They saw about 2,400 requests a day from several US cloud IP addresses, with a node user agent. They thought a script or a polling loop caused them. One evening, the CPU stayed at 100% for 40 minutes.

Below, we show how we found the cause, what we changed, and a checklist for your own headless WordPress site.

The setup: who talks to WordPress

Our site is headless. WordPress only stores the content. A Next.js app on another host builds the pages that visitors see.

  • The website reads content through the WPGraphQL, in three languages (EN, FR, RU).
  • Visitors and bots never connect to WordPress directly. Server sends them the pages and serves most pages from its cache.

This last point is important. When server rebuilds a page, it asks WordPress for the data again. So each page view that is not in the cache causes work for WordPress.

Diagram: visitors and crawlers reach the Next.js app. Cached pages are served at once. On a cache miss, the page is rebuilt and the app queries the WordPress API.
Two caches in front of the database. Publishing clears both.

Our site rebuilt the blog list on every visit, because its pagination ran through URL parameters. A cache was there from the start, but Next.js cached only the pages, not their URL-parameter versions. So each crawler visit to that page caused work for WordPress.

What the logs showed

The node user agent was the first clue. Next.js sends this user agent when it gets data on the server. The US cloud IP addresses matched the default region of our hosting platform. So our own app sent the requests. The next question was: why did the app ask so often?

The request logs of our hosting platform gave the answer. We exported 23 hours of logs with its command-line tool. Then we counted the pages that the platform rebuilt instead of serving from its cache.

PageRebuilt in 23 hMain visitor
/blog (all 3 languages)1,344ClaudeBot (1,073), then MJ12bot, PetalBot etc
/worksabout 90mixed
Everything elsealmost always cached—

The error logs and the deploy history gave two more findings:

  • Scanners requested paths like /.env and /ads.txt. Our home page read .env as a language code and sent a query to WordPress with it. Return 404.
  • Builds matched the worst evening. Our hosting platform ran six full builds in 40 minutes. Each build got every page in three languages.

The 30-day history from our host gave one more clue. On two of the worst days, we deployed almost nothing. Builds caused short spikes, but crawlers caused the constant load.

Why it happened

No single bug caused this. Four usual decisions added up.

  1. One page was never cached. Our blog list reads ?page= and ?tag= from the URL. Because of this, Next.js renders the page again on each request. A crawler that tries all page and tag combinations rebuilds it many times. Each rebuild also counted all posts, six at a time, only to show the page numbers.
  2. Data expired every hour. We cached all WordPress queries for one hour. Between two crawler visits, the cache had often expired. So the rebuild asked WordPress again.
  3. Every release built the site three times. Our hosting platform built a full copy at each step of our review process.
  4. WordPress did not cache the queries. We had WPGraphQL Smart Cache installed. But its object cache, the part that works with our POST requests, was off. So each query ran PHP and the database in full.

Points 1 and 4 were not new. They existed for more than a year. The traffic changed: AI crawlers now visit much more often, and our robots.txt allowed them.

What we changed

We made five small changes. None of them needed new infrastructure.

ChangeWhereWhat it does
Disallow ?page= and ?tag= in robots.txt, Crawl-delay: 10 for botsNext.jsCrawlers do not visit the uncached URL variants. Search engines still find posts through the sitemap.xml
Cache WordPress data for 24 hours, not 1 hourNext.jsEach page gets new data from WordPress once a day at most
Return 404 for unknown language codes before any queryNext.jsScanner URLs like /.env do not reach WordPress
Build only production, one build at a timeHosting settingsOne build per release, not three builds at the same time
Turn on the Smart Cache object cache, 24 hoursWordPress settingsRepeated queries, including most queries of a build, do not use the database

The first change worked in one night. ClaudeBot rebuilds of the blog list went from about 84 every two hours to zero.

Diagram: a page rebuild checks the Next.js data cache (24 h), then the WordPress object cache, and only then runs the query. When you publish, a webhook clears the data cache and Smart Cache clears the object cache.
Two caches in front of the database. Publishing clears both.

A query gets to the database only when both caches miss. Publishing clears both caches, so new content shows in a few seconds.

Results

After five days, WordPress got 176 GraphQL requests a day. This is more than 90% less than before, and the server had no CPU faults.

MeasureBeforeNov 4Nov 5Nov 6Nov 7Nov 8
GraphQL requests to WordPress2,400–2,900—1,196334335176
CPU limit faultsmost daysa fewsomea fewa fewnone
Production builds3 per release25010
Blog list rebuildsabout 1,30026843815942
ClaudeBot visits to the blog list1,0734—000

Each column shows 24 hours from about 09:00 Paris time. A dash means that we have no figure for that day. On Nov 6, our own development work caused more than half of the requests.

The remaining faults came in short bursts. Each production build gets the whole site at once. On coding days, our own development work adds more requests. Our last fix was a WordPress setting: the Smart Cache object cache. It keeps answers long enough to cover the time between two releases. On Nov 8 we had no release, so our next build will show the result.

Next step: the blog list (not finished yet)

This part is still in progress. The blog list still links to its own variants: the HTML of /blog has 11 links like ?page=2 and ?tag=seo. Crawlers follow each link, so one page becomes dozens of URLs. A sitemap does not stop this, because crawlers follow links first. Our robots.txt rule stops polite crawlers, but it does not fix the cause.

We publish about two posts a week, so each language will have about 100 posts in a year. The fix must scale. We compare three options:

OptionHow it worksForAgainst
All posts on one pageThe list page has all posts. Page numbers and tags filter in the browserSimple. One static page. No URL variantsThe page gets heavy with 100 or more posts
Pages in the URL path/blog/page/2, /blog/tag/seo. Each page is static and has only its own postsScales to hundreds of posts. Real URLs that search engines can indexMore routes. Old ?page= links need redirects
Partial rendering (Next.js 16)A static page shell. The list part is cached for each page and tagSmall change in the page codeNeeds a major Next.js upgrade. Crawlers still follow every ?page= link

None of these options fits us fully yet: each one fixes part of the problem and adds a new cost. So we keep looking, and we will add the result here.

Diagram: a visit to /blog goes through the Next.js page cache, the 24-hour data cache and the WordPress object cache before it reaches the database. Most visits stop at the page cache.
Three cache layers, from the visit to the database. The database works only after we publish.

Whatever we choose, the goal stays the same: a normal visit stops at the page cache, and the database works only after we publish. Publishing clears all three caches, so new posts still show in a few seconds.

Checklist for your headless WordPress site

Does your WordPress host report heavy GraphQL traffic? Check these points before you buy a larger plan.

  1. Find who sends the requests. A node user agent means your own frontend. A bot name means that the bot calls WordPress directly.
  2. Find the pages that are never cached. In your hosting logs, look for routes that always show MISS. Usually, these are pages that read search parameters.
  3. Check who visits these pages. If most visitors are crawlers, block the URL variants in robots.txt. Do not block the full page.
  4. Keep your cache longer. Cache WordPress data for one day. Use a webhook to refresh it when you publish. Do not get new data every hour.
  5. Count your builds. Each preview build gets your full site. If you do not use previews, build only production.
  6. Turn on the object cache. In WPGraphQL Smart Cache, select Use Object Cache. Change the expiration from the 10-minute default to 24 hours or more. It works with POST requests and clears itself when you publish.
  7. Reject junk URLs early. Before you query WordPress, return 404 for each URL that is not a real route.
  8. Control your filter and page URLs. Give pages and filters real, cacheable URLs, or keep them out of links. Otherwise one list page becomes dozens of URLs for crawlers.

Is your site slow or too expensive? We will look at your logs with you

Talk to us