Skip to content
Mohamed Naser

10 Database Bottlenecks We Hit Scaling to 5,000 Orders

In January 2025 a stress test of ten automated orders left four of them stuck. Eighteen months later the same platform ran 5,200 orders end to end at 99.94%. Ten case files on the code and database work that got it there.

10 Database Bottlenecks We Hit Scaling to 5,000 Orders
  • database bottlenecks
  • mysql lock contention
  • read committed isolation
  • database deadlocks
  • database performance tuning
  • missing index
  • scaling laravel
  • performance engineering
  • stress testing
  • fintech scaling

As Part 1 explains, a Murabaha order is not one transaction but a strict legal sequence — purchase real commodity units, transfer ownership to the borrower, sell to the open market, complete — none of which may be skipped, faked, or reordered. Each order fans out into a chain of queued jobs touching the hottest tables in the system: market orders, inventories, and millions of individually coded units. Making one such pipeline correct is ordinary engineering. Making five thousand of them run concurrently, with every legal constraint intact, is where the last two years of work went.

What follows are the ten battles that made the difference, in the order we fought them — the ones fought inside the code and the database. The infrastructure battles that came after, once the code had stopped being the constraint, are Part 3. We have kept the scars visible. Each case tells you what was actually going wrong, what we tried — including the ideas that did not survive contact with production — and what the numbers said when the dust settled.

CASE 01

Everything waits for everything

Aug 2024 – Jan 2025

Impactceiling: tens → hundreds of orders

The problem

Our first real batch test, in December 2024, was humbling. We created 31 orders at once and watched three of them freeze for more than twenty minutes. When we retried with just ten orders, everything passed — and that, honestly, was the platform's ceiling at the time. A few weeks later a test run confirmed it the hard way: ten automated trade requests, four stuck in Purchasing Commodity.

Digging in, we found a platform designed for one order at a time, politely queuing the whole world behind it. Fifty simultaneous orders were processed strictly stage by stage — the first order could not buy its commodity until all forty-nine others had finished searching for theirs. Certificates rendered through a headless browser that fell over under load and took whole orders down with it. Stuck orders kept their commodity units reserved forever, so even cancelling them failed. And our very first "stuck orders" investigation ended somewhere nobody expected: the server disk was 98% full — 44 of 45 GB. Not a race condition, not a deadlock. A full disk.

A dense traffic jam, cars barely moving.
The order pipeline, late 2024: every order waiting for every other order (dramatization).
What we tried

We fixed the unglamorous things first: bigger disks. Then we gave the PDF service room to breathe — longer timeouts, more browser sessions, Chrome kept warm — which helped, though in hindsight we were treating a symptom we would only cure properly a year later (Case 04). The changes that mattered were structural. Work that ran synchronously inside requests moved into queued jobs. Jobs started carrying lightweight record IDs instead of whole serialized objects, because one stuck serialization had been pinning entire orders. And the single shared queue became three dedicated worker containers. Three. It felt like a fleet at the time.

The result By mid-2025 the platform was surviving real volume: 388 orders in one hour in May, then 534 of 535 completed in June — the first run where the entire cycle finished unattended, with no human touching a single order. The ceiling had moved from ten orders to hundreds, and we finally had a platform worth stress-testing.
CASE 02

The database fights back

Aug 2024 – Aug 2025

ImpactP95 28s → 13s · slow tail 21.9% → 0

The problem

Then production started sending a new kind of message: Lock wait timeout exceeded. Orders that should have flown were standing in line behind a single SELECT … FOR UPDATE, like shoppers queuing at the one open till. Underneath it sat something subtler: the database's default isolation level — REPEATABLE READ, safe and strict — quietly locks the gaps between rows, and when many transactions touch neighbouring inventory rows, those gap locks collide into deadlocks. We had the strange experience of adding capacity and watching throughput fall. More concurrency meant more waiting.

What we tried — three rounds

It took three rounds to actually win. In the first, we treated each incident as it came: remove this lock, log that deadlock (innodb_print_all_deadlocks on, so at least the next one would leave a confession). Every individual fix worked — and the class of problem kept coming back with a new address.

In the second round, April 2025, we went after the class itself and switched the platform to READ COMMITTED: giving up a strictness the Murabaha flow never actually needed, in exchange for dramatically less lock contention.

The third round came during the summer stress tests, when the sell path deadlocked anyway — the global setting was not reaching every transaction, so the hottest services got it explicitly. And the last deadlocks were removed the cheapest way of all: not by locking more, but by making every code path write to tables in the same order, so transactions could no longer meet head-on.

The result — 1,000-order stress test, Aug 2025 We ran 1,000 orders through the fixed pipeline and measured. Average purchase time fell from 11.81s to 7.8s. P95 from 28s to 13s. The worst order of the entire run: 14 seconds, down from 65. And the slow tail — the 21.9% of orders that used to take over 15 seconds — vanished entirely. Every one of the thousand finished within 3–14 seconds.
CASE 03

The purchase-path campaign

Jul – Sep 2025

Impact1,000 orders, all within 14s

The problem

That summer we set ourselves a target — 500 orders without a single error — launched a 300-order run, and watched the first eleven orders die before doing anything at all. The culprit was a setting nobody had ever had a reason to think about: the framework's default 60-second job timeout. No job was ever supposed to run for a minute. The timeout was not the disease; it was the thermometer — and it was telling us the purchase path simply did too much, one thing after another, for every single order.

What we tried

We raised the timeout to 120 seconds the same day — not as a fix, but as anesthesia, so testing could continue while we operated. Then came the actual surgery, a deliberate summer campaign. Buying commodities, creating trader orders, and holding units each got their own queue instead of sharing one line. The search for eligible commodities was separated from order initiation, so five workers could do in parallel what one had been doing alone. Inventory scans switched to cursors. We even dropped foreign keys from the busiest join table — heresy in most engineering conversations, but their integrity checks were pure cost on a table written millions of times a day, and the application already guaranteed those invariants itself. And the units table finally got its first serious indexes.

The result The 1,000-order August test in Case 02 was this campaign's report card too: every order within 3–14 seconds. But the quieter outcome mattered more. From August 2025 the business team began running weekly performance tests with recorded percentiles — the moment performance stopped being a firefight and became a habit.
CASE 04

The certificate tax

Dec 2024 – Aug 2025

Impact0 seconds per step

The problem

Every legal step of a Murabaha produces its evidence — a commodity certificate, an ownership certificate, a sell confirmation. For a long time nobody questioned where those documents were made: inside the step itself, by a headless Chrome browser that opened a full browser session for every single PDF and produced, at best, about 18 documents a minute. It was like closing the till to print each receipt on a printing press in the back room. Under load the press jammed, its queue backed up, and whole orders stood still — waiting not for money or commodities, but for paperwork.

A letterpress printing machine producing one card at a time.
The certificate press at work: one document at a time, on the critical path of every order.
What we tried — three attempts

First, we tuned the press. Longer timeouts, more concurrent sessions, Chrome kept warm and ready. It bought headroom for the next test — and changed nothing about the economics. Still one browser per document, still on the critical path.

Then we replaced the engine. A native PHP library went head-to-head against the browser and won convincingly: ~128 documents a minute against ~18 — seven times the throughput — with no external service, no per-document fees, documents staying on our own infrastructure, and proper Arabic right-to-left out of the box. Better in every way. And still seconds of rendering inside every step.

The winning move came from a different room. Sitting with the business and the Sharia requirements, we asked what the certificate actually had to be — and the answer was: it must exist and be obtainable, not be rendered before the step may complete. So the steps stopped generating PDFs entirely; a certificate renders the first time someone downloads it. And here is the quiet punchline: most certificates are never downloaded at all. Most of that work never needed speeding up. It needed to stop existing.

ATTEMPT 1 · BROWSERLESS ATTEMPT 2 · mPDF ATTEMPT 3 · ON DEMAND ~18 docs/min ~128 docs/min 0s on the step one Chrome session per document, on the critical path native PHP engine, 7× throughput — still seconds inside each step rendered only when someone actually downloads it
The three attempts at the certificate tax, at their true relative speeds: a browser session per document (~18/minute), a native PHP engine (~128/minute) — and finally no render at all unless a user asks for the document. The fastest PDF is the one you never generate.
The result The certificate cost on the pipeline dropped to effectively zero seconds per step — you can see it in any order today, where Borrower Ownership and Murabaha Completed finish in 0 seconds (the live order in Part 1 shows exactly this). When someone does request a certificate, it renders in about 3 seconds at seven times the old throughput. At 5,000 orders and five certificates each, the on-demand decision removed tens of thousands of renders from every test run — the largest single time sink we ever took off the critical path. And it came out of a business conversation, not a profiler.
CASE 05

Which units can you even buy?

Dec 2024 – 2026

Impactscan millions of rows → read a counter

The problem

Before a single unit can be purchased, the platform has to answer a question that sounds like a database query and is actually a small legal investigation: which of the millions of units is this particular customer allowed to buy, right now? A unit qualifies only if it is free, its supplier is active, and buying it would not violate the trade-rotation rules for that specific buyer — a unit you recently owned cannot rotate straight back to you.

In the early days we answered honestly and expensively: scan the units table, per order, with that customer's conditions. Millions of rows, thousands of concurrent orders, the same question over and over. For a long time, if you asked where the platform's time was going, a large part of the answer was: re-deriving an answer that had barely changed since the last time we derived it.

What we tried — three generations

Generation one: compute the truth on demand. Correct, simple, and ruinously expensive — every order paid the full scan, and those scans were most of the read pressure behind the 94-million-read index in Case 09.

Generation two: the "settled" feature. When an order completes, mark its units as settled back to the market — in bulk, with a cheap flag — so eligibility queries can skip whole classes of units without inspecting them. The scans shrank. But the settlement chain groaned under its own weight (its jobs kept blowing past their time limits and eventually needed a full bulk rewrite), and the per-order scan was still there. Smaller, not gone.

Generation three: stop scanning altogether. If the question is always the same, keep the answer written down. A dedicated table now holds an eligible-quantity counter for every (customer, inventory) pair, and every event that changes the answer — a purchase, a settlement, a supplier switching on — updates the counter in that same moment, atomically. The schema itself refuses to let a counter go negative, and rebuild jobs can reconstruct any counter from the ground truth whenever we have a reason to doubt it.

The result "Find eligible commodities" went from scanning millions of unit rows on every order to reading a handful of precomputed counters — roughly 0.1 seconds inside today's sub-second purchase step. The cost of eligibility moved off every order's critical path and onto the rare events that actually change the answer. That is the whole trick, and it took three generations to find it.
CASE 06

The exact-change problem

Jan 2025 – 2026

Impactgateway timeouts → milliseconds

The problem

Knowing which units are eligible still leaves the genuinely hard part. A Murabaha for, say, SAR 1,000,000 must be covered exactly by whole units with fixed prices — like paying an exact amount using only the banknotes in your wallet, when the wallet holds thousands of notes in dozens of denominations. Mathematicians know this family of puzzles as "exact change" problems, and they are famous for one thing: naive solutions explode. Ours did. On inputs of a few hundred inventories, the first implementation searched until the gateway itself hung up on it — and a slow search does not fail one order, it stalls the purchase step of every order queued behind it.

How we made it fast

The version that survived thinks like a very fast cashier. It counts in whole halalas, never fractions — floating-point money that drifts by 0.01 is the difference between exact change and no change at all. Before searching, it checks whether an answer can even exist: if the most valuable 10,000 units on the table cannot reach the amount, the order fails instantly with a clear message instead of hunting for a solution that is not there.

Then it gets greedy: one linear pass through the eligible inventories, taking as many units of each as still fit in the remaining amount. For the vast majority of orders, that single pass lands exactly on zero. When a gap survives, it backtracks — puts a note back, swaps in different ones — and writes down every gap it fails to close, so even a failure comes out as a diagnosis: the market lacks units at these prices.

And it is kept on a deliberate leash: at most 10,000 units per trade, at most three seconds of thinking. One order failing fast with a useful message beats five thousand orders waiting behind a search that refuses to give up.

The result Combinations for the overwhelming majority of orders are found in milliseconds, inside a purchase chain whose entire CPU budget is about half a second. The impossible cases fail immediately — and instead of a timeout, they leave behind a list of the missing price points: market feedback, not just an error.
CASE 07

The platform attacking itself

Oct 2025

Impactdatabase load −85%

The problem

One October morning the monitoring dashboard reported 228,291 database queries in a few hours — on a system doing almost nothing. We went looking for the traffic and found ourselves. A scheduler ran every minute and, with no duplicate check, had dispatched the same background job 22,400 times. A single unindexed history lookup had run 114,000 times. The first alarm had been, embarrassingly, the monitoring bill: millions of recorded events per day, all of them faithfully documenting our own waste.

Two identical Spider-Men pointing at each other.
The moment we traced the 228,291 queries back to their source.
What we tried

The fixes read like an apology to the database. One composite index turned the hot lookup from a scan into a seek. A cached "last action" column meant roughly 90% of those history queries no longer needed to run at all. And the scheduler grew up: it loads work in batches, skips anything it touched in the last five minutes, checks what is already queued before dispatching again, and staggers its dispatches with a small random delay — so a thousand jobs stop arriving in the very same second.

The result Platform query volume dropped roughly 85% in every environment — one environment alone went from 5.5 million to 825 thousand queries a day — and monitoring event volume fell about 72%, which also ended the debate about observability costs. The report's own conclusion said it best: the monitoring "wasn't the problem — it was faithfully monitoring legitimate performance bottlenecks."
CASE 08

The regression the baseline caught

Nov 2025

Impact2s per order recovered in a week

The problem

In November, the weekly numbers twitched. The purchase step, steady around seven seconds for weeks, came in at nine — then nearly ten. Nothing had failed: every order completed, no alarms fired, nobody complained. Which is exactly what makes this kind of bug dangerous. Without a baseline, two extra seconds on every order quietly becomes the new normal, and nobody ever asks why.

What we tried

The percentile history let us bisect by deployment, and the answer was uncomfortable: the slowdown came from a bug-fix. A certificate had been naming the wrong previous owner; the correction was right, but it did its checking in the middle of every single purchase. We reverted it in production the same week — protecting the step while the fix stayed alive in testing — and then rebuilt the same correction so it did its work off the critical path.

The result Purchase times returned to baseline, with the certificate fix intact. The deeper win was cultural: this became the standing proof that every functional change is also a performance change until the percentiles say otherwise. Per-step duration tracking was built into the product itself soon after, so today every order records how long each of its steps took.
CASE 09

The coded-unit ledger battles

2024 – 2026

Impact3,300 orders · 100% · ledger intact

The problem

As Part 1 explains, stock lives as individually coded units, each tracked to its owner, with every ownership change appended to a legal ledger by a database trigger. That units table is the hottest object in the platform — one of its indexes alone had served 94 million reads when we first profiled it — and it answers to three masters at once: no two orders may ever hold the same unit, the ledger may never miss a row, and finding eligible units among millions has to stay fast. Almost every scaling battle we fought eventually landed on this table.

What we tried — including what failed

Exclusive holds took years of iteration: counters that tolerate crashes without drifting, a repair job for corrupted holds, overlap middleware on the jobs that could collide — and one queue deliberately left at a single worker, because for that step, correctness genuinely could not be parallelized. Knowing when not to scale is also scaling.

The table's indexes have a history like a battlefield map: added, removed, rebuilt as virtual-column composites, extended, pruned again. Every index makes the reads faster and the millions of ownership writes slower, so each one had to earn its keep with measurements — index hygiene became a recurring chore, not a one-time setup.

One experiment deserves its own confession. We added model-level caching to the hot tables to save reads — and months later reverted it for the write-heavy ones, because under real load the caching cost more than it saved. It stung. It is also exactly why we measure.

And the trigger met its match at settlement scale. The per-row trigger guarantees no ledger row is ever missed — and it became the slowest part of bulk settlement. We did not remove the guarantee; we changed who writes. The settlement rewrite produces the very same ledger rows through set-based bulk statements, thousands at a time, with the trigger stepped around only for that audited path.

The result March 2026: 3,300 orders, 100% completed — millions of unit holds, transfers, and ledger rows, with zero double-holds and zero missing links in any chain of ownership. The ledger held at every scale the stress tests ever reached.
CASE 10

When the jobs themselves fail

2024 – 2026

Impactfailed job ≠ failed transaction

The problem

Everything in this platform is a job — every step transition, every certificate, every settlement. That is what makes 5,000 concurrent orders possible, and it carries a sober corollary: whatever happens to a job happens to a customer's transaction.

In the early platform, a job's life could end three ways, and all of them were bad. A failed job simply died — there was no retry, so a database hiccup lasting one second froze a customer's Murabaha until an engineer went in by hand. Nothing stopped two workers on two different servers from grabbing the same work — and in a system that moves money and transfers ownership, doing something twice is as dangerous as not doing it at all. And when we first added locks to prevent that, the locks never expired: a worker killed mid-job left its lock behind forever, blocking the order with a "max attempts exceeded" error whose cause you could never find — because the cause was a ghost.

What we built

The platform we run now starts from a different assumption: every job will eventually die mid-flight — the work is making that boring. Jobs retry on their own, waiting 1, 2, 3, 5, then 30 seconds between attempts: patient enough for a transient fault to clear, quick enough that the order barely notices. If we had to name the single most important mechanism behind zero failed transactions, it is this one — most failures are temporary, and a platform that retries well simply does not lose them.

Critical jobs are declared unique per order across the whole fleet, so a duplicate dispatch from any server is refused at the queue itself, not caught after the damage. The locks learned to die: they expire shortly after the worker timeout, so a crashed worker's lock heals on its own, and an overlapping dispatch politely re-queues after a short delay instead of burning through its retries on the spot. Even deploys stopped being a hazard — a worker being shut down now finishes or hands back its job instead of taking it to the grave.

The result A failed job stopped meaning a failed transaction. Transient faults heal themselves within seconds, duplicates are impossible by construction, and dead workers clean up after themselves. The handful of orders per 5,000-run that still strand — the genuinely non-transient failures — are exactly what the audited admin "Force Forward" tooling exists for. The 99.9%+ completion rates in this article are not the absence of failures. They are failures, handled.
RECORD

The scaling ladder

2024 – 2026
0 1,000 2,000 3,000 4,000 5,000 Dec 2024 Jan 2025 May 2025 Jun 2025 Aug 2025 Feb 2026 Mar 2026 Apr 2026 Jun 2026 → Dec 2024 — 31 orders (3 stuck; disk 98% full) Jan 2025 — 10 orders (4 of 10 stuck) May 2025 — 388 orders in one hour Jun 2025 — 535 orders, 534 completed Aug 2025 — 1,000 orders, P95 13s, zero over 15s Feb 2026 — 2,500 orders per run × 5, 99.92–100% Mar 2026 — 3,300 orders, 100% completed Apr 2026 — 5,200 orders end-to-end, 99.94% From Jun 2026 — 5,000 orders, routine daily test 31 10 388 535 1,000 2,500 × 5 runs 3,300 5,200 5,000 daily
Largest successful stress-test scale per period, in orders per run. The bars for 31 and 10 orders are drawn at minimum visible width — at this scale they are two pixels, which is the point. The lighter final bar marks 5,000-order runs becoming a routine daily test rather than a one-off campaign.
DateTest scaleOutcome
Dec 202431 orders3 stuck >20 min; 10-order retest passed — root cause: disk 98% full
Jan 202510 orders4 of 10 stuck in Purchasing Commodity
May 2025388 orders / hrHandful stuck at initiation and ownership
Jun 2025535 orders534 completed — first full auto-sell cycle
Aug 20251,000 ordersPost-fix: P95 13s, zero orders over 15s
Feb 20262,500 × 5 runs99.92–100% success, ~48–50 min per run
Mar 20263,300 orders100% completed
Apr 20265,200 orders99.94% — 3 stuck, end-to-end
Jun 2026 →5,000 ordersRoutine daily performance test; create-order API ~1.2s avg
DECISIONS

Business logic as a performance lever

Throughout

Threaded through the cases above are wins that were not technical at all — product decisions about what the Murabaha flow actually requires:

  • Auto Complete Sell. Allowing companies to opt into automatic contract, wakala, and sell steps is what made unattended 5,000-order runs possible at all — and mirrors how high-volume lenders want to operate.
  • Certificates on demand. Sharia-required documents are still produced — but at download time, off the order's critical path (Case 04).
  • Trade rotation count. A controlled pre-production A/B (five 2,500-order runs before and after) proved that reducing commodity trade rotations from 2 to 1 carried no performance penalty, clearing the way for the Specialized Entity flow on business merits alone.
  • Force Forward. At scale, some tiny fraction of orders will always strand. Rather than database surgery by engineers, admins got an audited per-step "force forward" action, with automatic order expiry as the second safety net.
  • A standing performance program. Weekly (now daily) pre-production stress tests with recorded percentiles, quarterly performance epics, and dedicated worst-case scenarios — performance became a product feature with an owner, not an emergency.

Ten orders once broke this platform. By the end of these ten cases, five thousand ran end to end at 99.94% — and the last of them had already changed the shape of the problem. With the code no longer the constraint, the waiting had moved somewhere else entirely: into the space between the jobs, and into a worker fleet nobody had designed on purpose. That is Part 3.

← Blog