← All posts
Performance Engineering · Weframe Tech

Medusa Under Black Friday Load

A capability test that turned out to be a system design problem.

Good morning. My name is Akash Sharma, and I've been an intern at Weframe Tech for about five months.

When I was asked to write a technical blog, my first topic came back rejected — not deep enough, they wanted something more substantial. I don't have years of experience to draw on, so I picked a subject that sounded genuinely interesting and decided to learn it properly.

The question I chose was: can Medusa handle a Black Friday checkout sale?

From the outside that looks like a test of Medusa, and for part of it, that's exactly what it is. Some of the things I was asked to examine — inventory locking, order consistency — really are questions about the platform. Medusa answers those well. In every test I ran, it never oversold a single unit.

But the headline part of the question isn't about Medusa at all. Running hundreds of thousands of checkouts at the same time is not something any single machine does, however good the software on it is. That part is a system design question: how many machines, arranged how, with what underneath them.

So that's what I built a test for.

01

Two ways to grow a system

When one machine isn't enough, you have two options. Only one of them works here, and understanding why is the foundation for everything that follows.

VERTICAL — ONE BIGGER MACHINE you run out of machine Buy more CPU and memory for the one server you already have. HORIZONTAL — MORE MACHINES Run more copies of the same server and share the traffic between them.
Vertical scaling means making one machine bigger. It has a hard limit — you eventually buy the biggest server that exists. Horizontal scaling means running more copies. There's no equivalent limit; you keep adding machines.

Horizontal scaling only works if the copies don't need to know about each other. That's what "stateless" means.

Stateless — and why it matters here

A stateless service remembers nothing between requests. It keeps no data of its own in memory, so any copy can serve any customer at any time, and you can add or remove copies freely.

Medusa's API is stateless. Every piece of durable information — your cart, your order, the stock count, your session — lives in the database or in Redis, never inside the Medusa process. So if your "add to cart" is served by copy #1 and your "checkout" by copy #4, it doesn't matter: both read the same cart from the same database.

The database and Redis are the opposite. They are the memory, so you can't just clone them. That difference is the whole design problem, and it comes back in question 5.

02

The lab

I built a complete Medusa v2 store and sent real checkout traffic at it: browse the catalogue, create a cart, add an item, enter addresses, pick a shipping method, create a payment session, and complete the order. Nine API calls — exactly the ones a real storefront makes.

k6 fake shoppers balancer shares traffic out Medusa 1Medusa 2Medusa 3 Medusa 4Medusa 5 identical copies — we ran 1, then 2, then 3, then 5 PostgreSQL carts · orders · stock Redis locks · cache

How a request travels. k6 is the tool that pretends to be shoppers — it fires the same nine API calls a real customer would, over and over. Those requests hit the balancer, which hands each one to the next Medusa copy in turn so the work is spread evenly. Whichever copy receives it does the same thing: read and write to the one shared PostgreSQL database, and use one shared Redis for locks and caching.

The important detail is on the right. However many Medusa copies we run, there is only ever one database and one Redis underneath all of them.

Everything runs on one laptop — so how is that fair?

A modern CPU is divided into cores — independent workers that each handle one stream of instructions at a time. My machine has 16. Normally every program competes for all of them, which would be a problem here: if the fake-shopper tool grabbed cycles from the database, I'd be measuring the fight, not the software.

So I gave each part of the system its own cores and forbade it from using any others:

k6 · 2
1
DB · 2
1
Medusa · 10 cores (2 per copy, so up to 5 copies)

2 cores for the fake-shopper tool · 1 for the balancer · 2 for PostgreSQL · 1 for Redis · 10 left for Medusa. Because each Medusa copy gets 2 cores, ten cores means a maximum of five copies — which is why the test stops at five.

ComponentDetail
Host machineAMD Ryzen 7 5800HS · 16 cores · 16 GB RAM
Commerce platformMedusa v2.17.2, production build, Node 22
DatabasePostgreSQL 16
Cache & locksRedis 7
Fake shoppersk6
Catalog205 products, one of them held at exactly 5 units
03

The test

I ran the same test four times — with 1, then 2, then 3, then 5 copies of Medusa — and counted how many checkouts finished.

Each copy was given 10 shoppers of its own. So one copy served 10 shoppers, two copies served 20, three served 30, and five served 50. This matters: I'm not taking the same crowd and splitting it thinner across more servers — I'm growing the crowd along with the fleet, which is what actually happens on Black Friday. If five copies really do five times the work, five copies should finish roughly five times as many checkouts.

Each run lasted four minutes, and I ran all four one after another in a single sitting so nothing changed on the machine in between.

Two different numbers come out of a test like this, and it's worth being clear that they measure different things.

Throughput and latency

THROUGHPUT — HOW MANY FINISH per minute Higher is better. This is what the business counts. LATENCY — HOW LONG ONE TAKES clickdone Lower is better. This is what the customer feels.

They can move in opposite directions. A shop can be finishing plenty of orders per minute while every individual customer waits far too long. You need both numbers to know how you're doing.

p95 — how we measure "how bad does it get"

If you take every request and sort them from fastest to slowest, the p95 is the one sitting 95% of the way down the list. In plain terms: 95 out of every 100 requests were faster than this; 5 were slower.

fastest 95 of every 100 requests land in here slowest 5 p95

We use it instead of the average because averages hide the worst cases. If 99 requests take one second and one takes a hundred, the average says two seconds and sounds fine — but someone had a terrible experience. On Black Friday, "the slowest 5%" is thousands of real people, so it's the number worth committing to.

The target I set before running anything: p95 under 2 seconds per API call.

04

The questions

01

Can it handle hundreds of thousands of checkouts?

You cannot generate Black Friday on one laptop, and any benchmark claiming otherwise is inventing numbers.

But you don't have to. This is exactly what horizontal scaling is for. If one copy of Medusa handles a certain amount of work, and adding copies keeps increasing the total, then reaching Black Friday volume becomes a matter of running enough copies — a question of budget and architecture rather than a limit in the software. So the useful test isn't "can one laptop survive a million shoppers." It's "when we add copies, does the total keep going up?" That I can measure honestly.

050100150200250 checkouts finished perfect 5× growth 5089126175 1.8× more2.5× more3.5× more 1235 copies of Medusa
What we wantedMore copies should finish more checkouts.
What we gotThey did — 1.8×, then 2.5×, then 3.5× as many checkouts — though each new copy added a bit less than the one before.

Every copy we added finished more orders than the fleet before it. Nothing plateaued and nothing went backwards. That is the answer to the question: Black Friday volume is reachable by running more copies, so it's a matter of provisioning rather than a wall in the platform.

The one imperfection is that five copies gave 3.5× rather than a full 5×. That gap isn't Medusa falling short — it's the result of a deliberate choice we made in how we built the test, and question 5 shows exactly what it was and how to fix it.

YesAdding copies always increased total checkouts. Scaling out works.
02

Inventory locking

This is the scenario every commerce engineer has a bad dream about: more buyers than stock, everyone clicking at the same instant.

The danger is overselling — taking payment for items you don't actually have. It happens because two customers can read the stock count at the same moment, before either has finished buying.

WITHOUT A LOCK — THE PROBLEM Buyer A sees 5 left writes 4 left Buyer B sees 5 left writes 4 left 2 items sold, only 1 removed Both read before either wrote. The shop now owes an item it doesn't have. Neither buyer did anything wrong — they simply overlapped. WITH A LOCK — THE FIX Buyer A takes lock sees 5 left writes 4 releases Buyer B waits its turn sees 4 left writes 3 2 sold, 2 removed stock stays honest A lock is a "one at a time" rule on a row: whoever holds it works alone, and everyone else queues. It costs a little waiting. It makes overselling impossible.

Medusa does this for you. Its checkout takes the lock, reserves the stock, and either commits the reservation when the order completes or gives it back if anything fails. We wrote none of that.

The one thing we configured was where Medusa keeps its locks. With five separate copies of Medusa running, a lock held in one copy's own memory would be invisible to the other four — and they'd happily oversell around it. So Medusa is pointed at Redis, which all five copies share. Redis is the single notebook every copy writes its locks into, so a lock taken by copy #1 is immediately visible to copy #4.

To test it, I sent 40 shoppers making 80 purchase attempts at one product holding exactly 5 units — spread across three separate copies of Medusa, so the copies genuinely had to coordinate through Redis rather than just with themselves.

80
purchase attempts
5
units in stock
5
orders created
75
politely refused
0
oversells

Checked directly in the database afterwards: five units stocked, five reserved, exactly five order lines. The 75 unsuccessful shoppers got a proper "out of stock" response — not a timeout, and not a half-created order.

PassMedusa's own locking held perfectly across five independent copies.
03

Payment processing

Payment is the step everyone assumes is the slow one. I timed each of the nine steps separately to find out.

pick shipping6.84s place the order5.09s enter address3.60s add to cart3.45s create cart2.00s list shipping1.65s browse products1.10s payment setup0.95s payment session0.44s 0s7s
The two payment steps are the fastest things in the entire checkout. Picking a shipping method is the most expensive step by a wide margin.

The reason picking a shipping method costs so much is worth knowing, because it's the same thing that makes the whole checkout expensive. Medusa runs every checkout step as a workflow — a recorded sequence of operations. As each step runs, Medusa writes its progress to the database, so that if something fails halfway through, it knows exactly what to undo. That's what stops a failed checkout leaving a half-made order behind, and it's the machinery behind the clean rollbacks in question 7.

It isn't free. Looking at which queries consumed the most database time across the whole test, updating workflow progress was the single most expensive query of all — and the workflow table was read more than four thousand times across 126 checkouts. Roughly 36 database operations per checkout exist purely so Medusa can undo the order safely. That's a deliberate trade: some speed, in exchange for never leaving the data in a broken state.

There's also a design reason payment is so cheap here, and it's worth understanding because it holds true in production too. Medusa doesn't wait for the payment to clear before finishing the order. It creates a payment session, completes the order, and then the payment provider confirms separately a moment later. So even when the provider is slow, that slowness sits in one isolated step instead of holding the whole checkout open.

What this test does and doesn't tell you. We used Medusa's built-in test payment provider, which approves instantly without contacting anyone. So these numbers measure Medusa's side of payment — the work it does to create and record a payment — and that side is genuinely fast and well-designed. What they don't include is the trip out to Stripe or Razorpay, which typically adds a few hundred milliseconds. Adding that would push the payment step up, but it wouldn't change the ranking: payment would still be far from the most expensive part of this checkout, and thanks to the design above, a slow provider delays one step rather than everything.

PassPayment is the cheapest part of checkout, and built so a slow provider can't block the rest.
04

Redis performance

Redis is a very fast store that keeps everything in memory rather than on disk. In our setup it does two jobs: it holds the inventory locks from question 2, and it caches data so Medusa doesn't have to re-fetch it. Because all five copies of Medusa share it, if Redis were slow, everything would be slow.

So the question is simply: did Redis ever struggle? One Redis instance can comfortably handle over 100,000 operations per second. Here's what we actually asked of it.

WHAT ONE REDIS CAN HANDLE 100,000+ operations / second we peaked at 233 about 0.2% of what it can do Each operation took around 40 µs (millionths of a second), and Redis used under 4 MB of memory.
At its busiest moment, Redis was doing 233 operations per second — while simultaneously holding every inventory lock that kept question 2 correct.

Redis was never remotely close to being a problem, and that's the right outcome. It's the one piece every copy of Medusa must share, so it needs enormous spare capacity by design. Worth monitoring; not worth worrying about.

PassUsed 0.2% of its capacity. Never a constraint.
05

PostgreSQL — where the real limit was

This is the most important result, and it's a lesson about how we built the test.

We added copies of Medusa and left the database completely untouched. One PostgreSQL, two cores, default settings, from the first run to the last. Five copies of Medusa, all sending their work to the same single database.

CORES USED, OUT OF CORES GIVEN MEDUSA 1.9 of 2 3.2 of 4 8.0 of 10 cores at five copies Always had room to spare — because we kept giving it more cores. POSTGRESQL — GIVEN 2 CORES, ALWAYS 1 copy 0.25 of 2 cores 2 copies 0.57 of 2 cores 3 copies 0.97 of 2 cores 5 copies 1.94 of 2 cores By five copies the database was essentially full — 97% of everything it had.
What we didGrew the Medusa side and gave it more cores each time. Never grew the database.
What happenedMedusa always had headroom. The database went from a quarter of one core to almost exactly full.

That red bar filling up is the reason five copies gave 3.5× instead of 5×. It isn't a limitation of Medusa — it's arithmetic. Five times the traffic arrived at a database that never got any bigger.

What makes the database work so hard is that a single checkout runs about 394 database queries. Not errors — successful queries, each taking a millisecond or two. They're that numerous because Medusa is built from separate modules (products, pricing, cart, inventory, payment, orders), and each module fetches its own data rather than sharing one big combined query. Browsing 20 products alone touches products, variants, options and prices as separate lookups. On top of that, Medusa records every step of the checkout so it can undo the order safely if something fails partway. Multiply that by every copy in the fleet and the database gets busy fast.

As a quick check, I added a cache in front of the product listing so repeat browsing didn't hit the database at all. Completed checkouts rose 42% immediately. That's a useful confirmation: take load off the database, and the whole system speeds up.

The honest conclusion is that we only scaled half the system. To straighten out that curve, the database has to grow alongside Medusa.

Half-scaledThe database was the limit — because we never scaled it.
06

Autoscaling

Autoscaling means not deciding by hand how many copies to run. You set up a watcher that keeps an eye on one measurement, and when that measurement crosses a line you've drawn, it starts extra copies automatically — then shuts them down again when traffic drops. It's how a shop survives a sale without paying for a huge fleet all year.

watcher checks every few sec "how busy are the servers right now?" limit busy-ness climbing as traffic grows crosses the limit → start more copies copies running

The hard part is choosing which measurement the watcher looks at. The most common choice is database CPU — and our results show that would have been a bad decision here.

What the watcher could measureReading with 1 copyWould it have acted?
Database CPU — the usual choice0.25 of 2 coresNo
Medusa's own CPU1.9 of 2 coresYes
How long requests take13.6 sYes

With one copy running, the database looked almost idle while customers were waiting thirteen seconds. A watcher pointed at the database would have concluded everything was fine and done nothing. Watch Medusa's CPU and how long requests are taking — those are the two that noticed immediately.

One warning that follows from question 5: every new copy also adds load to the database. So autoscaling the Medusa side without a plan for the database eventually creates the problem we hit — the extra copies meant to fix slowness are what fill the database up.

Signal identifiedScale on Medusa's CPU and request time — not database CPU.
07

Order consistency under load

Throughput tells you how many orders you took. This tells you whether you can trust them.

0
deadlocks
0
stuck waiting
75
clean refusals
0
half-made orders

A deadlock is when two operations each wait for something the other holds, and neither can ever continue — the database has to kill one of them. We had none, at any fleet size, in any run.

More importantly, the 75 shoppers who didn't get an item during the stampede were each rolled back completely. No order was created without stock behind it; no stock was set aside for an order that never existed. When I counted rows in the database afterwards, the stock and the orders agreed exactly.

That's the outcome that matters. Under heavy load this system got slower, but it never got things wrong — and those two failures are not equally bad. Slowness costs you some sales that day and is fixed by adding machines. Incorrect stock means shipping items you don't have, refunds, apologies, and customers who don't come back.

PassThe data stayed correct even when the system was overwhelmed.
05

In summary

Can Medusa handle a Black Friday checkout sale? Yes — and the questions split cleanly into two groups.

The parts Medusa is responsible for, it handles well. Inventory locking, order consistency, payment design and its use of Redis were all correct under real pressure. Eighty shoppers raced for five units across three separate copies and got exactly five orders, with no deadlocks and no phantom stock. That's the failure that would genuinely hurt on the biggest day of the year, and it never happened.

The parts system design is responsible for are where the work is. No single machine serves Black Friday, so the real question was whether capacity grows when you add copies — and it does. Every copy we added finished more orders than the fleet before it. We got 3.5× from five copies rather than a full 5×, and that shortfall was our own doing: we grew Medusa and left one small database serving all of it.

What we'd do next, and what it should buy

Three concrete changes, in order of expected impact:

  1. Give the database more resources. It ran on two cores the entire time and finished at 97% full. This is the single biggest constraint and the cheapest thing to change.
  2. Add a connection pooler in front of it. Every Medusa copy opens its own set of connections, and PostgreSQL handles each one as a separate process. A pooler lets many copies share a small number of real connections, so the fleet can grow without the database drowning in connections.
  3. Reduce the 394 queries per checkout. Caching the product listing alone lifted completed checkouts by 42%. Extending that idea — caching more of the read-heavy steps, and letting copies of the database serve reads — attacks the root cause rather than the symptom.

With the database scaled alongside Medusa, I'd expect that curve to run much closer to straight. I can't put a precise figure on it without running the test, but the 42% we got from one cache change on one step suggests there's a lot of room.

The target we missed

We set out to keep p95 under 2 seconds per request. Our best result was 13.6 seconds — roughly seven times over. Part of that is the machine: a single laptop was running the fake shoppers, the database, Redis and five copies of Medusa all at once, which is not how any of this would be deployed. But part of it is the database bottleneck above, and that part is fixable.

So the answer to the question I started with is this. Medusa does its job — it stays correct under pressure, it never oversold, and it's built to run as many copies as you need. What decides whether a store survives Black Friday isn't Medusa. It's whether the system around it — the database, the caching, the connection handling, the scaling rules — was designed to grow with it. That's the part that needs the engineering, and that's where I'd spend the next round of work.