How We Redesigned Our Background Job Architecture for Faster Bundle Processing
A flash sale on one store was silently delaying inventory sync for every other merchant. Here's the queue design fix that made processing fair.
AI Product Bundle Builder
5 min Read

The complaint wasn't about a bug. No orders were lost, no inventory was corrupted. But a merchant kept telling us their inventory sync felt slow, and they couldn't understand why. Their store wasn't busy. They'd get one or two orders, and the stock update would take longer than it should.
We looked at their store in isolation and nothing was wrong. Then we looked at what was happening across all stores at the same time, and the problem was immediately obvious.

Every order triggers several background jobs
When a merchant's store receives an order that includes a bundle, our app doesn't process it inline. It queues background jobs to handle the work asynchronously.
Those jobs cover:
Processing the bundle order
Updating component inventory
Syncing stock across bundled products
Recording bundle analytics
Triggering any follow-up operations
That's the right approach. Doing this synchronously during checkout would be slow and fragile. Background jobs are the standard pattern.
The problem wasn't the pattern. It was how we organized the queue.
Single queue for all the merchants was the wrong call
When we first built this, all background jobs went into a single shared Sidekiq queue. Every store, every order, every job type, all in one line.
At low volume, it was fine. Jobs processed quickly and nobody noticed any ordering effects.
As the merchant base grew and order volumes increased, the single queue became a fairness problem.
Here's what was actually happening. Say four stores are all using the app on the same day:
Store A runs a flash sale and places 1,000 orders in two hours
Stores B, C, and D are quiet, each getting a handful of orders
Store A's 1,000 jobs flood the queue. Any job from Store B, C, or D that arrives after the flood starts has to wait behind all of them. A merchant with one order to process ends up waiting for another merchant's entire flash sale to clear.
From the outside, it looks like slow inventory sync. But their store isn't slow. They're just waiting in the wrong line.
We were already using Sidekiq throttling to cap concurrency, but throttling doesn't fix this. It limits how many jobs run at once, not which jobs get to go first. The queue was still shared. The fairness problem was still there.
The fix: every store gets its own processing lane
The core insight was simple. If stores are sharing a queue, a busy store will always affect quiet ones. The only real solution is to stop sharing.
So we changed the architecture. When a Shopify store installs and loads our app, we dynamically create a dedicated Sidekiq worker for that store. Every job for that store routes to its own worker, completely isolated from every other store's workload.

Now the same four-store scenario plays out differently. Store A's 1,000 jobs go into Store A's worker. Store D's single order goes into Store D's worker and processes almost immediately. They're not in the same line anymore.
Why we process one job at a time per store
Each dedicated worker processes one job at a time for its store, not in parallel.
That's deliberate. Bundle inventory updates have to be sequential. If two jobs try to update the same component's stock at the same time, you get race conditions and incorrect numbers. Processing jobs one at a time per store keeps inventory consistent without requiring complex locking logic.
The trade-off is worth it. A store that generates 1,000 orders still processes them sequentially, but that only affects that store's own queue. Everyone else moves independently.
What this actually changed
The before/after for the quiet merchant with one order is the clearest measure. Before: wait behind however many jobs were already in the shared queue, which on a busy day could mean a noticeable delay. After: their job starts almost immediately.
For high-volume stores, throughput stayed the same. They still process sequentially, they still get through their queue at the same rate. They just don't get to hold up anyone else while doing it.
A few other things improved as a side effect:
Failure isolation. If a worker for one store hits a problem and backs up, it doesn't cascade to other stores.
Cleaner monitoring. You can look at a specific store's worker and see exactly how its queue is behaving. With a shared queue, you're looking at aggregate noise.
No flash sale surprises. When a merchant runs a promotion, their queue grows. It used to be everyone's problem. Now it's only theirs.

The broader lesson from this
More servers wouldn't have fixed this. More concurrency wouldn't have fixed this. Throttling, which we already had, didn't fix this.
The problem was a design assumption baked in early: that one queue is simpler and good enough. It was simpler. It wasn't good enough once the merchant base grew.
Scaling isn't always a hardware or concurrency problem. Sometimes it's a fairness problem baked into how you've structured the work. The fix here wasn't expensive, but finding it required looking at the system from the perspective of the merchant experiencing it, not just the metrics that showed everything was technically running.
If you're seeing inventory sync delays on AI Product Bundle Builder and your store isn't particularly busy, this architecture is why it's gotten faster. The queue your store sits in is your own.
Questions about how AI Product Bundle Builder handles inventory sync? Get in touch.
