HelpWithWebGet Help Now
← Back to Blog
Performance7 min read

Site Goes Down Intermittently? Stuck Imports Are Eating Your PHP-FPM Pool

If your site briefly returns 502s then recovers, you don't have a slow-hosting problem. You have a stuck-import problem starving the PHP-FPM pool. How to confirm it, and how to fix it on WordPress, Craft CMS, and anything else PHP.

ByDino Bartolome
Code editor with colorful syntax highlighting on dark background
Photo by Florian Olivo on Unsplash

If your site has been working fine, then briefly returning 502s or timeouts, then working fine again, you almost certainly don't have a slow-hosting problem. You have a PHP-FPM pool starvation problem. And the most common cause is a recurring import or sync that quietly locks workers and never releases them.

This is the failure mode that makes "everything looks normal on the server" intermittent outages so hard to debug. The hardware is fine. The CPU is fine. But six of your ten PHP-FPM workers are stuck on an inventory sync that hit a hanging external API call three hours ago, and only four workers are left to serve real visitors. When those fill up, the site appears down. When the stuck workers finally time out and respawn, it works again.

We've seen this exact pattern on WordPress, Craft CMS, Magento, and custom Symfony apps. It is not framework-specific. It is a PHP-FPM architecture problem that every PHP framework has its own way of triggering.

The mechanism

PHP-FPM has a finite pool of worker processes. The size is set by pm.max_children. Every concurrent request needs a worker for the full duration of that request. (We covered how to size the pool in a separate post.)

Visitors borrow a worker for ~100ms and release it. Background tasks are the problem. An inventory sync that hits a stuck cURL call to a vendor API holds a worker for 30 seconds, 5 minutes, or forever if nobody set a timeout.

If your pool is 10 workers and 6 are stuck on hanging imports, you have 4 workers for everything else: page loads, search, admin clicks, the AJAX from your live chat, your monitoring agent's heartbeat. Burst real traffic into those 4 slots and you get queue buildup, then 502s. From the visitor's perspective the site went down for 90 seconds and came back. From your monitoring's perspective, response time spiked then recovered. No alert fires because the workers eventually time out, the pool recovers, and the uptime percentage looks fine.

The pattern across frameworks

Most PHP frameworks ship a "background task" system that, by default, runs in the same web pool as visitor requests. This is the architectural mistake.

  • WordPress has WP-Cron, which is triggered by visitor requests. If a scheduled task hangs, the visitor request that triggered it hangs too, and every subsequent visit until cron's spawn-throttle expires.
  • Craft CMS has the Queue system, which by default runs queued jobs via web requests too. A stuck ResaveElements job or a Feed Me import that hits a hanging vendor API holds a worker until the job times out.
  • Magento has cron, which can be run via bin/magento cron:run from system cron, but stuck consumer jobs (especially indexer rebuilds and inventory sync) still steal FPM workers if they were triggered from the admin.
  • Symfony / Laravel custom apps have the messenger or queue component. If you forgot to set up a worker process and the queue runs synchronously on the web request, every dispatched message is a stuck worker.

The fix is the same regardless of framework: get the recurring work off the web FPM pool and onto a separate process. We'll cover the exact recipes per framework below.

How to confirm it's this and not something else

SSH into your server. Run:

`` ps -eo pid,etime,cmd | grep php-fpm | sort -k2 -r | head -20 ``

The etime column is how long each worker has been alive. A healthy site has workers cycling. The numbers should mostly be under a minute.

If you see workers with etime in the tens of minutes or hours, those are stuck. That is your problem.

Better diagnostic, if you have PHP-FPM status enabled (add pm.status_path = /fpm-status to the pool config):

`` curl http://localhost/fpm-status?full ``

This shows you every worker, what request URI it is processing, and how long. Anything over 10 seconds on a typical site is worth investigating.

The third place to look is the FPM slow log. In your pool config, set slowlog = /var/log/php-fpm-slow.log and request_slowlog_timeout = 10s. Any request taking longer than 10 seconds gets its full PHP stack trace dumped. Run an import, watch the slow log fill up with the same backtrace, and you have found your culprit by name.

The usual suspects

In our experience, the recurring tasks that cause this are almost always one of:

  • WP-Cron triggered by visitor requests (WordPress default). One hanging cron job + visitor traffic + no DISABLE_WP_CRON = pool starvation.
  • Craft CMS Queue running on web requests (Craft default). Element resaves, Feed Me imports, image transform rebuilds, Commerce order syncs.
  • REST API endpoints used by inventory syncs. Shopify, Square, WooCommerce, custom ERPs, PIM systems. They POST to a webhook on your site every N minutes. If the handler does anything heavy, the worker is held for the full duration.
  • save_post / save_entry hooks doing inline heavy work. Plugins that recalculate prices, sync to a remote system, or rebuild search indexes on every save. Import a CSV with 5000 products and you've held a worker for the entire import.
  • Stuck cURL calls to external APIs. A vendor API became slow, your code has no timeout, and now every recurring task is waiting on a connection that will never respond.
  • Database queries with no LIMIT in nightly reports. A report that ran fine on a 10k-row table now runs on a 2M-row table and takes 40 minutes to fail.

The fix, in three layers

Layer 1: timeouts (band-aid)

Set request_terminate_timeout = 60s in your php-fpm pool config. Any worker stuck for more than 60 seconds gets killed and respawned. Set max_execution_time = 60 in php.ini for the same thing at the PHP level. And set explicit cURL timeouts on every outbound HTTP call: CURLOPT_CONNECTTIMEOUT = 5, CURLOPT_TIMEOUT = 30.

This stops the bleed but does not fix the underlying issue. Stuck imports will still be stuck, they will just be killed after 60s. You'll see partial imports, half-synced orders, and weird inconsistent state. Layer 2 is where you actually fix it.

Layer 2: chunking

Long-running tasks should never run inline in a web request. A CSV import that processes 5000 rows in one request will hold a worker for minutes. Split it: process 50 rows per request, queue the next batch, return immediately. The user sees a progress bar, the server sees a series of short requests.

Most plugins and frameworks already have chunking primitives. WooCommerce has Action Scheduler. Craft has the Queue system (which we will get off the web pool in Layer 3). Magento has the indexer / consumer pattern. Use them.

Layer 3: separate worker pool (the real fix)

Move all recurring tasks off the web FPM pool entirely. The architecture is: web pool serves humans, worker pool serves robots and recurring tasks. They never share workers, so they never compete.

The exact recipe depends on framework:

  • WordPress. Add define('DISABLE_WP_CRON', true); to wp-config.php. Then add a system cron job: */5 * * * * cd /path/to/wordpress && wp cron event run --due-now (requires WP-CLI). Now cron runs in its own CLI PHP process, never touches FPM.
  • Craft CMS. Edit config/general.php and set 'runQueueAutomatically' => false. Then run php craft queue/listen as a daemon (under systemd or supervisord) or */1 * * * * /path/to/craft queue/run from system cron. Jobs run in CLI, the web pool stays clear. This is the single most impactful change you can make on a busy Craft site.
  • Magento. Run bin/magento cron:run from system cron and make sure your consumers (bin/magento queue:consumers:start) are running as background processes, not started from the admin.
  • Symfony / Laravel custom apps. Run messenger:consume (Symfony) or queue:work (Laravel) as a daemon under systemd or supervisord. The web request dispatches messages, the worker process consumes them.

Once Layer 3 is in place, your FPM slow log goes quiet, your intermittent outages stop, and your site can absorb sync failures from vendor APIs without taking visitors down with it.

What to actually do tomorrow morning

  1. Run the ps -eo pid,etime,cmd | grep php-fpm check during a known-slow window. If you see workers stuck for minutes, you've confirmed the problem.
  2. Enable the FPM slow log and let it run for a day. The slow log will name your worst offenders.
  3. Add request_terminate_timeout = 60s as the immediate band-aid.
  4. Look at the slow log offenders one by one. Most are WP-Cron, a Craft queue job, an inventory sync handler, or a stuck external API call.
  5. For each: add cURL timeouts (5 min of work) and move the work to chunks or to a real CLI worker (an afternoon of work).
  6. Get cron and queues off the web pool entirely. This is the structural fix.

The thing nobody tells you

Most "you need more RAM" or "you need a bigger VPS" advice from hosts is masking exactly this problem. Bigger pools just give you more workers to be stuck. The real fix is keeping recurring tasks off the web pool so visitor traffic never has to share workers with background work.

We have seen sites running on 1GB VPSes with no intermittent outages because their cron and queues are CLI-only, and sites running on 16GB VPSes that go down every afternoon because their entire Magento indexer is running through the same FPM pool as the storefront. The pool architecture matters more than the pool size.

---

*If your site has been going down intermittently and your host says everything's fine, this is the first thing we check. Free 30-minute audit at dino@helpwithweb.com. Bring your slow log if you have one, we'll tell you exactly which job is holding workers.*

Need Help With Your Website?

I fix these problems every day. Send me a message and I'll take a look.

Get Help Now