Skip to content
Mohamed Naser

Scaling Queue Workers: From 3 Containers to 122 Processes

A purchase step that took seven and a half seconds contained 521 milliseconds of actual work. The rest was queue wait. Five case files on the infrastructure half of the story: splitting queues, sizing a worker fleet, and the deploys that quietly skipped it.

Scaling Queue Workers: From 3 Containers to 122 Processes
  • scaling queue workers
  • queue worker fleet
  • queue wait time
  • docker compose scale
  • laravel queue
  • redis queue
  • worker autoscaling
  • graceful shutdown
  • rolling deploy
  • infrastructure consolidation

Part 2 ends where the code stops being the problem. Every case in it was a fix you could point at in a diff: an isolation level, a missing index, a transaction boundary, a rendering engine. What follows are the five battles that came after β€” the ones fought in the space between the jobs rather than inside them, where the code is innocent and the topology is the product.

The cases stay numbered continuously with Part 2, because they happened in the same two years and often in the same week. As before, each one tells you what was actually going wrong, what we tried β€” including the attempts that had to be reverted β€” and what the numbers said afterwards.

CASE 11

The seven seconds between the jobs

May – Aug 2026

Impactwait ~118s β†’ β‰ˆ0 Β· 5,000 daily

The problem

By mid-2026, 5,000-order runs were routine β€” and the daily test showed the Borrower Ownership step taking around two minutes for 4,000 of the 5,000 orders. The decisive clue came from an exercise anyone can copy: we added up the actual CPU time of every job in the purchase step and got 521 milliseconds β€” inside a step that observably took seven and a half seconds. The code was innocent. The other seven seconds were spent standing in line. And queue wait has a cruel personality: invisible when the system is idle (~0s), noticeable under load (~7s at 91 orders a minute), catastrophic at peak (~118 seconds).

Mr. Bean standing in an empty field, checking his watch and waiting.
Actual footage of a Borrower Ownership job, 0.5 seconds of work done, waiting its turn behind the auto-sell queue.
What we tried β€” and the bottleneck that moved

The fix was to stop making unrelated work share one line. Ownership transfers and auto-sales had been competing for the same eight workers; each got its own dedicated queue on its own server, and the two-minute wait evaporated.

Then the fix backfired β€” upstream. With that backpressure gone, the purchase step nearly tripled, from 3.6 to 9.9 seconds. All the throughput we had just freed slammed into a 700,000-row table that had no index on the one column every duplicate-check searched β€” a full table scan that had been hiding behind the queue the entire time. One index later, the job was back under a second.

The rest was housekeeping that only matters at this scale: fifteen log lines per order deleted (about 100 MB of logs a day at 5,000 orders), unused indexes dropped to cut write cost, and explicit connection budgets after the full worker fleet exhausted the database's connections.

BEFORE Β· ONE SHARED QUEUE AFTER Β· DEDICATED QUEUES Borrower Ownership Auto-sell to market shared processing queue 8 workers, one host both job types compete queue wait: ~0s idle β†’ ~118s at sustained peak Borrower Ownership Auto-sell to market ownership queue dedicated workers sell queue dedicated workers job runtime ~1s Β· queue wait β‰ˆ 0
The queue split at the heart of Case 11. The jobs themselves ran in about one second; nearly all of the two-minute step was waiting behind auto-sell jobs in one shared 8-worker queue. Splitting them removed the wait β€” and promptly exposed the missing index upstream.
The result 5,000-order runs became a routine daily test, with the order-creation API averaging ~1.2 seconds while the pipeline runs at full load. Every queue split, though, leaves something new to run β€” which is how a platform that began with three worker containers ended up with a fleet to manage. That is the next case.
CASE 12

Running a fleet, not a server

2025 – 2026

Impact3 containers β†’ 122 workers Β· 3 nodes β†’ 2

The problem

Splitting queues, as Case 11 shows, is how you stop unrelated work from queuing behind itself. But every split leaves you with something new to run β€” and by 2026 the platform was no longer "an application on a server." It was 22 queues, one worker service each, and 122 worker processes spread across three machines, plus a scheduler. Every one of those processes is a small daemon holding database connections, consuming memory, and fully capable of ruining the others' day. And 122 is only the resting number: a rolling deploy brings the new generation up alongside the old one, so for a few minutes on every release the fleet is 244.

Nobody designs that on day one. It accumulates, and it accumulates asymmetrically: a queue that was busy in March is idle in July, a queue nobody predicted becomes the bottleneck overnight, and the machines you sized identically turn out to be doing wildly different amounts of work. At one point one server was running 63 worker processes while another ran 22. Left unmanaged, a worker fleet develops exactly the problems a city does β€” congestion in one district, empty streets in another, and infrastructure nobody remembers approving.

Every number in the fleet is a decision

ChangeWhy it changedWhat it did
Dedicated queues for ownership and auto-sell
Jul 2026
The two highest-volume job types were blocking each other on eight shared workers. 4 replicas each, on two machines. The ~118-second wait went to ~0 (Case 11).
trader_order_initiation 5 β†’ 10
Jul 2026
Removing the downstream backpressure exposed this queue as the next constraint β€” jobs waiting, not working. 10 at rest, 20 mid-deploy.
Unit-creation queue 5 β†’ 8
Jul 2026
It became the new tail of the purchase step once everything upstream got faster. 8 per machine at rest, 16 mid-deploy.
Rebalance four queues across machines
Jul 2026
One server carried 63 worker processes while another carried 22. 35 / 40 / 36. No queue's total concurrency changed β€” only which machine ran it.
Delete two dead workers
Jul 2026
No producer anywhere in the codebase. Five replicas were polling a queue nothing ever wrote to. Five processes reclaimed β€” and ten orphaned containers discovered still running.
Rename …units_logging β†’ …units_creation
Jul 2026
The name said logging; the worker created inventory units. Nobody could reason about the fleet from its own names. Old queue drained to zero before the cutover, so no job was stranded under the dead name.
Two new certificate queues
Aug 2026
Bid-certificate and order-result jobs were sharing one general-purpose queue with everything else in that integration. 5 replicas each on two machines β€” the change that takes the fleet from 122 to 142.
Order initiation pinned at 1
standing rule
Parallelism at this step is not safe: concurrent workers race each other. Never scaled, in any environment, deliberately.
How we manage it

The fleet is a file, not folklore. Every queue's worker count, per machine, is declared in the deployment script β€” so the entire topology arrives in a pull request instead of being discovered by SSH-ing into a box, and when a number changes the reason is in the commit. The catch is that a queue lives in four places: the Compose profile list, the steady-state scale block, the rolling-deploy scale block, and the service list the deploy iterates over. Miss one and the queue is deployed but not scaled, or scaled but never restarted onto the new image.

"Groups" are machines. The three Compose profiles map one-to-one onto physical servers in pre-production and production; the deploy runs all three in parallel, one per node. In development and sandbox the same file collapses onto a single box at one replica per queue. One description, three shapes β€” which is the only reason a fleet this size stays reviewable.

We add where the measurement points, not where the anxiety is. Every row in the table above began as a timing measurement showing a queue whose jobs were demonstrably waiting rather than working. None of them began as "this feels slow."

And we remove just as deliberately. Dead workers get deleted, misleading names get corrected, and a queue is drained to zero before it is renamed β€” because renaming a queue while jobs are still sitting in it is how you lose transactions. One queue is pinned at a single worker forever: the step where parallelism cannot be made safe. In a case study full of scaling-up decisions, the most important number in the fleet is a one.

Where a fleet bites back

A duplicate flag silently halves a queue. Compose keeps only the last value when the same service is scaled twice, so merging two profile groups onto one host quietly drops one queue from 8 workers to 4 and another from 5 to 2. No error, no warning β€” just a slower system and a load-test result that means nothing.

One undefined YAML anchor blocks every deploy. A Compose file referenced a *graceful-stop anchor that had never been defined on that branch. docker compose config failed β€” and since every deploy command parses the file first, all of them broke at once.

Deleting a service does not stop its containers. Removing a worker from the file leaves the running containers behind unless the deploy is explicitly told to remove orphans β€” and even then, containers whose profile is no longer active survive the sweep. Five of them came back after a routine reboot, restarted by their own restart policy, quietly serving a three-week-old image.

The real ceiling is connections, not CPU. In June 2026 a smaller environment was running all three groups at production replicas on one server: 86 worker containers against a database connection limit of 151, with an unbounded pool of web processes competing for what was left. The fix was a budget rather than a bigger database β€” web processes capped at 10 and recycled every 500 requests, small environments pinned to one replica per queue, which brought the whole environment to about 28 connections instead of 86.

Retiring a machine can retire the scheduler. The scheduler's profile was attached to the third server group, so decommissioning that group would have stopped every cron job on the platform without stopping anything anyone was watching. Caught while planning the consolidation; the trigger moved with it.

The result The fleet grew from 3 worker containers in January 2025 to 122 processes across 3 machines β€” and unlike the platform of 2024, every one of those numbers has a commit explaining it. The proof that the discipline is real is that it now runs in reverse: in August 2026 pre-production went from three machines to two with total queue concurrency unchanged, and the hardest part was proving that the second number had not quietly moved (Case 15). The goal was never "more workers." It was the right workers, in the right places, for reasons written down.
CASE 13

The workers were running last month's code

Jun 2026

Impactstale image β†’ every worker on the build

The problem

Jobs were finishing and their follow-up jobs were not starting. The chain simply stopped β€” no exception, no failed job, nothing in the logs to attach blame to. The follow-up jobs existed; they were sitting in a database table that nothing was reading.

The workers were running an image from an earlier deploy, and baked into that image was an old environment file naming the database queue driver β€” from before the platform moved to Redis. The application dispatched to Redis. The workers, still on the old build, wrote and read the database table. Every follow-up job in every chain landed in a queue nobody was watching.

Deploys had been reporting success the whole time. The web container picked up new code on every release; the workers had not picked up any.

What we tried

Two separate things had to be true before a worker could ever receive a build. The first was that Compose cannot detect that the contents behind a fixed tag have changed β€” every service referenced :latest, so docker compose up saw no reason to replace a container that was already running. Making the build's commit hash the image tag turns "new code" into something the tool can actually see.

That alone did not fix it. The rolling deploy scaled workers up with --no-recreate, which suppresses image swaps outright, and only the web container had an explicit force-recreate β€” so the scale up-and-down never replaced a single worker. Workers got their own force-recreate, scoped to the worker profiles and with --no-deps, because an earlier unscoped attempt had force-recreated the databases along with everything else and had to be reverted.

The same audit turned up a tax nobody had costed. Every worker was taking its full ten-second stop grace and then being killed outright on each deploy β€” the bulk of a roughly 140-second scale-down. The pcntl extension had never been installed, so the queue worker could not trap stop signals at all, and the stop signal inherited from the base image was not one it would have handled anyway. Installing the extension and sending the signal the worker actually listens for dropped idle shutdown from about ten seconds to about one.

The result Every deploy now replaces every worker with the build it claims to have deployed, and a fleet of 122 containers stops in roughly the time one used to. The lesson generalizes past this stack: a deploy that only verifies the web tier is not a deploy. On a platform like this one, the web tier is a thin edge in front of the queues β€” the workers are where the code actually runs.
CASE 14

The boring failures

Throughout

Impactsurprises moved out of production

The problem

Not every scale failure is clever. The stress-test program kept tripping over limits nobody had ever written down: a test server's disk at 98% (December 2024), and stress-test environments filling their disks with logs again in March 2026. A smallint column that overflowed when test data pushed past 32,000 registered companies β€” the database equivalent of an odometer rolling over. The default 60-second job timeout. The day the full worker fleet exhausted the database's connections. Deploys that killed whatever jobs were running.

The 'This is fine' dog sitting calmly in a burning room with a cup of coffee.
The platform at 98% disk usage, moments before our first stuck-orders investigation.
What we tried

Each one got the boring fix it deserved: standardized log rotation, wider column types, explicit connection budgets per environment (smaller environments hold ~28 database connections instead of ~86 at full scale), graceful worker shutdown. But the real fix is the habit around them: the stress tests run daily precisely so that the next limit nobody wrote down fails in a test, in pre-production, at dawn β€” instead of in production, at noon, with customers watching.

The result The last classes of surprise moved from production incidents to test findings. What still strands at 5,000-order scale β€” a handful of orders per campaign β€” is handled operationally: an audited per-step "Force Forward" action for admins, with automatic order expiry as the second safety net.
CASE 15

Two machines instead of three

Aug 2026

Impact3 nodes β†’ 2 Β· concurrency unchanged

The problem

By August 2026 the case for three application servers in pre-production was mostly historical. Sizing them down was the easy part. The surprise was finding out what they had been doing.

One node was serving 100% of the HTTP traffic. The second node's access log had been empty since February. The third received nothing but 404s. "Three web nodes" was a fiction: two of them had been worker-only for six months and nobody had noticed, because the workers kept working and the site kept answering.

The cause was a few lines of web-server config. The load balancer's health check is a bare request for / with no host header, so it lands on whichever virtual host the server treats as its default. Only one node had a catch-all default vhost β€” serving a static welcome page β€” so only that node answered 200. On the others the API's own hostname became the implicit default and returned 404 to a probe that had never asked for it. Permanently unhealthy, permanently out of rotation.

Worth noting what that probe does not do: the page it hits is static. It never touches the application. A completely dead app tier would have gone on reporting itself healthy.

What we tried

The consolidation was a redistribution, not a reduction. The retiring node's queues were reassigned across the two survivors with every queue's total concurrency held identical β€” the same 122 worker processes, split 66 and 56, weighted so the node that also serves HTTP carries fewer.

Three things nearly went wrong and were caught in review rather than in production. The scheduler's profile was attached to the group being retired, so decommissioning it would have silently stopped every scheduled job on the platform; the trigger moved first. Merging two profile groups onto one host would have passed a scale flag twice for the same service β€” and Compose keeps the last value, quietly halving two queues (Case 12). And memory had to be sized by the rolling-deploy peak rather than the resting fleet: workers idle at about 82 MiB each, but every replica doubles mid-deploy, which put the peak near 11 GiB on 8 GB boxes. Both nodes were resized to 16 GB before anything moved.

The result

Two nodes now run the same 122 worker processes at the concurrency three used to, on an identical image, with every queue depth at zero β€” and the node that had been invisible to the load balancer since February is back in rotation serving traffic.

One honest caveat, since this series keeps the scars visible: the worker split is still the asymmetric 66/56 that was drawn on the assumption that the second node had no web tier, and the three-node baseline it will be compared against had one node absorbing all the traffic. Any timing comparison across that boundary conflates two changes at once. The next run is the one that will mean something.

LESSONS

What two years taught us

  • Sum your job times before optimizing your job code. 0.52s of work inside a 7.5s step means the code is innocent; the queue topology is the product (Case 11).
  • Every fix moves the bottleneck. Removing backpressure downstream will expose the next unindexed query upstream. Budget for the second-order effect before celebrating the first (Case 11).
  • Every job will eventually die mid-flight. Retries with escalating backoff, per-order uniqueness, and self-expiring locks turned "failed job" from a stuck transaction into a non-event β€” retrying well is how you reach zero failed transactions (Part 2, Case 10).
  • Measurement must precede tuning. Per-step durations, weekly percentiles, and timing logs turned arguments into diffs. Our worst regression was caught in one week because a baseline existed (Part 2, Case 08).
  • The best optimization is not doing the work. Two engineering attempts made PDF generation faster; one business conversation made most of it unnecessary (Part 2, Case 04).
  • A worker fleet needs an owner. Twenty-two queues and 122 worker processes only stay sane if every count is justified in code review and removed as deliberately as it was added (Case 12).
  • A deploy that only verifies the web tier is not a deploy. Workers ran a stale image β€” and a stale environment inside it β€” for weeks while every release reported success (Case 13).
  • Health checks lie by default. A probe that never touches your application will call a dead application healthy, and a node that has been out of rotation for six months looks exactly like a node doing its job (Case 15).
  • Scale failures are usually boring. Full disks (twice), a smallint overflow, a default 60-second timeout, and log volume did as much damage as any deadlock (Case 14).
  • Isolation levels and locks are product decisions in disguise. READ COMMITTED, dropped foreign keys, trigger bypasses, and a deliberately single-threaded queue were all conscious trades of theoretical strictness for real-world correctness under load (Part 2, Cases 02 and 09).
  • Keep the failed experiments on the record. The caching that hurt, the fix that regressed, the timeout raise that only bought time β€” the retries are where the understanding came from.
  • The last 0.1% needs an operational answer, not a code one. At 5,000 orders, 99.9% success still strands five orders. Force-forward tooling and auto-expiry close the loop (Case 14).

Ten orders once broke the platform. Today, five thousand are a daily routine β€” and the same discipline that got us here (measure, split, index, budget, re-measure) is already pointed at the next order of magnitude.

← Blog