Two online stores. In both of them you can browse products, add them to the cart, pay by card, and check the order status. Both look professional and run fast, and the end customer probably will not notice any difference between them. One cost PLN 5,000, the other PLN 50,000.
The amounts are arbitrary. A store launched cheaply on a mature platform can be reliable, and an expensive custom-built system can have serious bugs. The difference I want to write about is something else: implementing a correct purchase flow is one scope of work, and designing a system that behaves predictably even when that flow fails is a much larger one.
The examples come from Artovnia, a live marketplace built on Medusa.js that I develop and maintain myself. They show a fragment of the actual implementation, measurements from production diagnostics, and load test results, rather than an idea of how such a system could work.
The happy path, or when everything works
Most implementations start with the happy path. The customer adds a product to the cart, goes to payment, the provider confirms the transaction, the backend creates the order, updates inventory, and sends a confirmation. This scenario is the easiest one to implement, test, and show to a client in a demo.
Production adds conditions that you do not see in a demo. HTTP requests time out even though the server has already completed the operation. Webhooks arrive several times or in a different order. Two people buy the last available item at the same moment. A worker stops working halfway through a job. The backend briefly loses its connection to PostgreSQL or Redis, one of the replicas stops responding, an external provider has an outage. An endpoint that responds in 100 ms with a few users responds in several seconds under heavier traffic, and a database that coped at launch becomes the slowest part of the application.
This is normal behavior for systems running on the internet. The cost only appears at the moment when the application has to know what to do in such a situation.
When servers stop communicating with each other
In a simple store it is enough to picture a single server that accepts requests, talks to the database, and returns responses. In practice the frontend communicates with the API, the API uses PostgreSQL and Redis, separate workers process jobs in the background, and payments, shipping, notifications, or invoicing may require communication with external services. Every one of those connections can fail.
The backend saves an order change in the database and tries to notify the component that is supposed to carry out the next step. The communication is interrupted. The order is updated, but the rest of the process never received the information, and if the application repeats the whole operation, some of the changes may be applied a second time. The situation looks similar with the database itself: the server sends a commit command and loses the connection before receiving a response. There is no way to know whether the transaction was committed, because a communication error does not mean that the operation did not go through.
That is why try/catch and retrying the request are not enough. You have to distinguish operations that can be safely repeated from ones whose outcome has to be established first. In Artovnia I use, among other things, durable records of financial operations, keys that make it possible to detect duplicates, and locks shared between backend replicas. Other mechanisms are responsible for retrying failed jobs and keeping track of their state.
Separating the API and the workers limits the impact of long-running jobs on handling user requests, although the processes can still compete for the shared database, Redis, and other resources. Network failures still happen, but in critical financial processes and selected asynchronous operations Artovnia stores state that makes it possible to establish the outcome of an interrupted operation and take further action.
The order is saved. What about the remaining jobs?
Paying for an order does not end the backend's work. A purchase confirmation still has to be sent, shipping notifications have to be handled, and information has to be passed on to external services, and there is no reason to keep the customer on the payment screen until all of those operations have finished.
Asynchronous mechanisms exist for jobs like these. Medusa.js has an Event Bus that passes events on for background processing. Artovnia runs an implementation based on Redis and BullMQ, configured for three attempts at a failed job with an increasing delay that starts from two seconds.
Once the attempts are exhausted, BullMQ may keep the job in a failed state, which in itself does not mean that anyone knows about the problem, will establish the cause, and will safely resume the operation. Hence the questions about a Dead Letter Queue, monitoring failed jobs, and controlled retries.
In Artovnia I extended email notification handling with this in mind. The administrator has a list of messages along with pending, success, and failure statuses, can check the history of attempts linked to the original message, and can manually retry a failed send. The data is not read from Redis or queried live from the provider. The panel uses its own admin endpoint, which through Medusa's standard Notification Module reads records from the notification table in PostgreSQL, narrowed down to the email channel and refreshed every 30 seconds.
The retry itself also requires safeguards. It uses a PostgreSQL lock, a fifteen-minute cooldown between attempts, and an idempotency key passed to the message provider, so that an attempt to solve one problem does not create another one in the form of several identical emails reaching the customer. A success status here means that the message was accepted for sending and recorded locally, not a confirmation that it landed in the recipient's inbox.
This solution covers email notifications, it is not a central DLQ for the whole store, and standard failed Event Bus jobs remain a separate matter. It does show the difference between having a feature and being able to maintain it. A retry alone is not enough, you also have to know what happened once it was exhausted and how to continue the process safely.
The provider accepted the payment, the backend did not get a response
The customer starts a payment, the backend sends the provider a request to create a Checkout session, the provider processes it, and then the connection is interrupted and the response never arrives. The simplest implementation will try again, and since every attempt creates a new request with a new identifier, the provider may create a second, independent session. This does not automatically lead to the customer being charged twice, but the system then has several payment attempts and has to know which one corresponds to the current cart.
In Artovnia the backend records the exact request parameters and an idempotency key before it calls the provider's API. Within the idempotency window supported by the provider, a retry with the same key and the same parameters limits the risk of creating a second independent operation. Providers do limit how long they keep information about such requests, so you have to decide how long it is acceptable to retry automatically and what to do afterwards. Once the safe window has passed, Artovnia does not send further requests, it treats the case as ambiguous and as requiring the actual state to be established.
During a normal purchase this logic will never run, and it will not be visible in a demo.
One payment, two servers, potentially two orders
After paying for the cart the customer returns from the provider's page to the store, and at the same time the provider sends a webhook about the successful transaction. Both processes may try to complete the same cart, and with several backend replicas they may land on different servers. Server A checks whether the order exists and does not find it. Server B performs the same check with the same result. Both start creating an order.
This is not about a faulty if condition. Two operations that are correct on their own produce an incorrect result when they run at the same time. This is a classic race condition, and its business cost is duplicated orders, double reservation of stock, and potential problems with settling the payment.

Click to zoom
Two parallel processes may try to complete the same cart. A shared PostgreSQL lock makes it possible to coordinate their execution across backend replicas.
In Artovnia's code it looks like this:
const result = await withCartPaymentLock(req.scope, id, async () => {
// This check must happen after acquiring the distributed lock. Otherwise
// a browser return and a Stripe webhook can both observe no order set and
// start the completion workflow on different API replicas.
const existingOrderSetId = await findOrderSetIdForCart(query, id)
if (existingOrderSetId) {
logger.debug?.(
`[POST complete] Cart ${id} already completed as order set ${existingOrderSetId}`
)
return { id: existingOrderSetId }
}
await assertCartHasNoCheckoutRefund(req.scope, id)
await ensureLoyaltyAdjustmentsOnCart(req.scope, id)
await ensureNewsletterDiscountAdjustmentsOnCart(req.scope, id)
return withValidatedCartDiscounts(req.scope, id, async () => {
const completion = await splitAndCompleteCartWorkflow(req.scope).run({
input: { id }
})
return completion.result
})
})What matters here is not the check for whether the order already exists, but the moment that check is performed. In Artovnia it only takes place after acquiring a lock shared between backend replicas. If the first process has already finished the cart, the second one finds the existing result instead of creating orders again.
The locking mechanism is provided by the Medusa.js locking module based on PostgreSQL, while the way it is used and the additional checks come from Artovnia's architecture. Both paths, the return from payment and the webhook, have to use the same lock key, and waiting for it has its own time limit. If the first completion attempt fails, the next request can take over the completion, as long as it does not time out first. That is why, besides the lock, you also need to verify whether a given payment session has already been used to create orders.
A refund is rarely a single API call
From the outside a refund looks simple. The administrator clicks "Refund funds", the backend sends a request to the provider and updates the order status.
Now let us assume that the provider carried out the refund, but the backend stopped working before it saved that information in the database. The outcome of the operation remains unknown, and retrying without checking may mean refunding the same funds a second time. On top of that come other cases: two administrators trigger a refund at the same time, a worker retries an interrupted operation, and in a marketplace a payout to a seller may be in progress at the same moment.
In Artovnia a refund is a separate, durable financial operation. Before the provider is called, the system creates a record with a unique identifier and reserves the relevant part of the available balance. PostgreSQL transactions and financial locks protect access to the same funds, and when an identifier is reused the system verifies whether it has already been used with different parameters. The result from the provider is a separate stage, and only its confirmation allows the local financial state to be changed. If execution is interrupted, a reconciliation process for that specific operation establishes the outcome and finishes or resumes the refund.
A retry answers the question of whether you can try again. Reconciliation answers the question of what happened during the previous attempt, and in financial operations that second answer usually carries more weight.
Artovnia's repository contains tests covering parallel balance reservations, a failure after the provider succeeded, expired locks, and a conflict between a refund and a payout. They use a real PostgreSQL instance, and the payment provider is simulated. This does not eliminate every possible production failure, but it does show that such situations were designed for and verified. I described why settlements in a marketplace work differently than in a regular store in the article Payments in Medusa.js for a store and for a marketplace are two completely different worlds.
The last item in stock
There is one unit left in the store, and two customers go to checkout almost at the same time. Both requests check availability and are told that the product can be bought. Without safeguards between the check and the stock reservation, both processes will try to sell the same unit. Adding another quantity check changes nothing, because the problem is concurrency, inventory reservations, and the order of operations.
In Artovnia there is also state synchronization and the possibility of changing the links between product variants and inventory items. Before the final reservation the system acquires locks, reads the current links again, and uses the Medusa Inventory reservation mechanisms.
That does not mean that every conflict is eliminated before payment. In the current architecture an initial availability validation happens earlier, and the actual inventory reservation only after the payment has been captured, so a scenario is possible in which the customer pays and the reservation fails. For a recognized lack of stock, Artovnia has a separate emergency refund mechanism. Designing a resilient system comes down to determining which situations can be avoided, where the risk remains, and what the system does when a process does not finish as planned.
Performance: endpoints and the database
A system can be logically correct and still become useless under heavier traffic. An endpoint returning a product list works instantly in a development environment and with a few users. Then an advertising campaign starts and many people browse the same category at once. Every request processes filters, fetches products, variants, prices, availability, and related data. If the endpoint runs too many queries, fetches unnecessary relations, or processes large amounts of data in memory, the load grows along with the number of requests, and with it response times, request queues, and timeouts. A problem in one endpoint can affect the rest of the application, especially when they share a process or a database connection pool.
The other half of this problem sits in the database, which is easy to forget about while optimizing images and Core Web Vitals. With little traffic, inefficient queries go unnoticed, and as the number of records grows they start scanning a larger part of the table. A poorly chosen index, an expensive JOIN, an inefficient execution plan, or an N+1 problem when fetching variants and prices can multiply the response time. If endpoints and workers take up all the available connections, further operations wait even when the queries themselves are cheap, and long transactions and locks delay operations that seem unrelated to each other.
It also happens that the problem does not lie directly in the application code. In Artovnia, cart operations could take 2.4-3.7 seconds, and the cause turned out to be an expensive JIT compilation of a single PostgreSQL query, triggered by an unfavorable execution plan. After disabling JIT, the execution time of that query dropped from more than two seconds to about 1.5 ms. Finding the cause required instrumenting the backend and analyzing SQL queries, rather than adding server resources. I described the whole diagnostic process in a separate article about the PostgreSQL JIT problem in Medusa.js.
When analyzing performance it is also not enough to look only at the average response time. During Artovnia's load tests I compared communication with PostgreSQL over a public and a private network connection. I checked each variant with k6 on a product page, with 100 virtual users, in runs of 5 minutes and 30 seconds, which gave about 10,700 requests per run and three runs per variant.
In both variants the median response time was almost identical, around 72-73 ms. A much larger difference showed up in p99, the time below which about 99% of the measured requests fall. In the runs studied it dropped from 550.51 ms to 223.35 ms, and the slowest single request from 1.93 s to 594 ms.

Click to zoom
A comparison of p95 and p99 response times during Artovnia's k6 tests. The median stayed practically unchanged, while the slowest requests reached lower times in the private connection variant.
This shows why performance is worth judging on the basis of the slowest requests as well. Two configurations can have an almost identical median and at the same time differ considerably in their behavior at the tail of the latency distribution. The measurement covers the compared infrastructure variants in these specific runs and is not a promise of performance under any production load.
Telemetry that measures HTTP request times, the work of SQL queries, and PostgreSQL pool parameters makes it possible to tie a slow request to the specific work performed inside the backend. It does not replace load tests or production monitoring, though, because it answers a different question: telemetry tells you what is happening now, while tests show how the system will behave under traffic that does not exist yet. A bigger machine will not fix a bad query architecture, it will only push back the moment when the problem returns.
What Medusa.js provides and what stays on the developer's side
Medusa.js provides ready-made e-commerce modules, among them Cart, Order, Payment, and Inventory, plus Workflows, event handling, and infrastructure modules, including locking. Workflows let you break a complex process into steps and define compensation functions that run in case of an error. That is a strong foundation, but it does not make every call to an external API safe, nor does it create a single transaction spanning PostgreSQL, the payment provider, and the remaining services. A database rollback will not undo a transfer that the provider has already made, so identifying the operation, recording its state, confirming the outcome, retrying, and regaining control when the result is unknown remain design decisions.
It is similar with performance. A modular architecture makes development easier, but a custom endpoint does not become resistant to load because of it, and the JIT compilation case shows that the source of a slowdown is sometimes one level below the application code.
It is similar with security. Medusa.js provides authentication and authorization mechanisms, but a custom endpoint still requires permissions to be defined correctly. The mere fact that a user is logged in cannot be enough to read someone else's order, change its status, or gain access to another customer's data. If a developer adds their own API, they also have to take care of securing it.
A similar responsibility appears with asynchronous processes. Medusa provides an Event Bus and retry mechanisms, but that does not mean that every failed business process automatically has its own monitoring, the ability to be safely replayed, and an established recovery procedure.
So what is the client paying for
Let us go back to the two stores from the beginning. In both of them you can place an order, both can have a good design, similar speed, and the same payment methods. The difference sits in work that you cannot see in a screenshot.
Someone designed the handling of errors and intermediate states, decided which operations have to be idempotent, when a transaction is needed, and when a transaction alone solves nothing. Someone accounted for competing requests, stale data, repeated webhooks, and failures between a write to the database and a call to an external API. Someone checked how the system behaves under concurrent load and exactly where the response time is lost.
On top of that come tests, and not only the ones checking whether a customer can buy a product, but also ones simulating a timeout after an operation has been performed, parallel refunds, or a worker being interrupted. Load testing and query profiling are separate work, without which it is hard to judge whether the application is ready for traffic growth. Finally there is maintenance: database schema changes, deployments, diagnosing slowdowns, logging operations, and the ability to resume them.
Not every application needs all of these mechanisms. Many of Artovnia's solutions come from the specifics of a marketplace, especially payment splitting, seller settlements, and integration with their existing stores. A regular store does not need its own payout ledger or synchronization across several platforms, but it should deliberately define what happens when critical operations fail and what load it has to handle. The price of development depends not on the number of screens, but on the scope of responsibility the system is meant to take over.
Renting or owning, SaaS versus Medusa.js
There is one more difference that does not show up in a quote: who owns the technology the business runs on, and how much control you have over it.
When you rent an apartment, you do not worry about the structure or the utilities. You can arrange it your own way, bring in furniture, change the color of the walls, sometimes carry out a bigger renovation, but the scope of the changes depends on the owner. SaaS platforms work in a similar way. Your brand, domain, data, and individually built storefront stay yours, while the engine responsible for running the store stays under the provider's control. The provider decides the direction in which the software develops, the available capabilities, the rules of the API, and the pricing.
The open Medusa.js core is available under the MIT license, which gives broad possibilities for using and modifying the code, although selected Enterprise solutions may be subject to separate terms. You can create your own modules, rebuild the logic of orders, payments, inventory, and integrations, manage deployments, choose the infrastructure, and decide the direction in which the application develops. The rights to code written specifically for your project depend on the contract with the contractor, so it is worth settling them at the start.
Your own house also costs something after you move in. In e-commerce that means hosting, updates, monitoring, incident handling, infrastructure security, and further software development. Your own system does not automatically mean a lower total cost of ownership, but it does give you the ability to make decisions that in a closed SaaS belong to the provider. With SaaS you pay primarily for the ability to use a ready platform. With custom e-commerce on Medusa.js you also invest in developing your own application and architecture.
That is why a store on Medusa.js can cost PLN 5,000 for a basic launch of a ready starter, but also one million PLN or more for an extensive enterprise implementation. The founder of Medusa.js himself points to a similar spread of prices:
You'll see Medusa shops being sold in all price ranges.
He also gave the approximate price brackets that he observes in offers from agencies and freelancers:
- EUR 1,000-10,000: smaller freelance projects.
- EUR 10,000-30,000: a standard store, delivered in 1-3 months.
- EUR 30,000-100,000: a mid-sized implementation, delivered in 2-6 months.
- EUR 100,000 to 1 million: enterprise implementations.
These are market observations, not a price list for the framework, and they concern mainly the American and European markets. According to him, the development of AI tools such as Claude and Codex has shortened project delivery times and contributed to lower quotes.
All of these projects can use the same framework. They differ in the scope of customization, the business requirements, and the amount of work needed for the implementation and the later maintenance. I compared the costs and features of both approaches in the article Medusa.js vs Shopify, an honest comparison for Polish stores in 2026.
When a PLN 5,000 store is enough
If you need a standard store, a ready and mature engine with existing integrations is sometimes more reasonable than building your own mechanisms from scratch. A simple store on SaaS can have solid payment and order handling, because those elements have already been worked out and are maintained by the provider. For a small store that is only starting to sell, that is often enough.
You are then paying for a different model of using technology, and some of the limitations will come from the capabilities of the platform, not from the developer's skills. If it later turns out that you need a custom purchase flow, extensive B2B, your own inventory logic, a specific payment model, or an unsupported integration, the cost of adapting the business to the platform may exceed the initial savings. It works the other way around too: if all your needs fit within the SaaS standard and you do not expect major changes, building your own system is sometimes not worth it.
It is also worth establishing what the MVP is supposed to be. A minimum version of the product does not need a loyalty program, advanced reports, marketing automation, or ten integrations, but from the start it should correctly handle taking money, creating orders, controlling availability, and access to data. It also does not have to serve tens of thousands of concurrent users on the first day, but it should have defined performance requirements and an architecture whose growth does not mean rebuilding the whole system at the first significant increase in traffic. A good MVP limits the number of features, not the quality of critical operations. The client should also know what limitations the purchased solution has and where today's savings may mean having to bear additional costs in the future.
What to ask about when getting a quote
When comparing offers it is worth going beyond the list of features and asking about specific scenarios:
- What will happen if the provider accepts a payment and the store does not receive a confirmation? How will the system handle two simultaneous attempts to buy the last item? Can a repeated webhook create a second order?
- What happens after the backend restarts in the middle of an operation, can interrupted jobs be resumed, and how does the system react to a temporary unavailability of the database or an external service? What will happen to a job that fails even after all the planned retries, and how will the administrator find out about the problem?
- What traffic is the system designed for, have load tests been planned, how will slow endpoints and queries be diagnosed, is anyone looking at p95 and p99 and not only at the average?
- Will you get access to the repository, who will hold the rights to the individually created code, can you change the contractor and move the system to different infrastructure?
With SaaS it is worth knowing which parts of the store belong to you, what data you can export, and what will happen if the provider changes the terms of the service.
Not every answer has to mean writing a custom module, because some of these problems are already handled correctly by the platform, a ready component, or a service provider. What counts is whether the contractor understands the problem, knows the responsibilities of the individual parts of the system, and can justify the proposed solution. A quote based on a list of screens does not show the full scope of the work, and an offer promising complete security, performance, and reliability without defining the boundaries of those guarantees should prompt caution.
What is left of all this
A store that correctly walks a customer through a purchase is not necessarily prepared for the failures that will happen sooner or later. A lock during cart completion, an idempotency key during payment, refund reconciliation, handling failed asynchronous jobs, and diagnosing PostgreSQL queries are work that the user will never see, and work that decides what happens on a worse day.
At the same time, Medusa.js gives you a lot of control over your own system and the freedom to build custom solutions. Along with them comes the responsibility for designing, testing, and maintaining that logic, because the framework provides tools, not ready-made architectural decisions.
So when planning a custom store it is worth asking not only what will work, but also what will happen when something stops working, and how the system will behave under traffic that does not exist yet. If you are planning such a project, I will gladly go through these scenarios using the specifics of your business. That is exactly what I do at AppCrates, building e-commerce stores and platforms on Medusa.js.


