← Blog

Magento

Magento 1.9 to Medusa.js: a five-week shadow cutover

An Enschede parts distributor on Magento 1.9 and PHP 5.6 had 90 days to get off. Here's the five-week shadow-traffic cutover that kept 18,400 prices intact.

Jacob Molkenboer· Founder · A Brand New Company· 15 Jun 2026· 9 min
Open leather logbook with brass key, green silk ribbon, iron tags and rubber date stamp on ivory paper.

On a Tuesday morning in February, the operations lead at an aftermarket-parts distributor in Enschede sent us a screenshot. Their staging Magento was throwing a fatal on every product save. PHP 5.6. mysqlnd warnings stacked four deep. The hosting company had given them 90 days to upgrade the runtime or be moved to a shared box that wouldn't run their codebase at all.

They had 18,400 dealer-tier price agreements in production. Twenty-eight people used the portal every day. The Exact Globe sync ran at 02:15 every night and posted invoices the next morning. A big-bang cutover was off the table.

Here is the five-week playbook we ran to get them off Magento 1.9 and onto a Medusa.js + Remix + Postgres stack with zero lost orders and no missed ledger entries.

Week 0: inventory before code

Before we wrote a line of new code, we mapped the old stack in a single spreadsheet. Three columns: entity, current source of truth, downstream consumer.

This sounds boring. It is the most important week.

The findings were not what the client expected:

  • Customer accounts lived in Magento's customer_entity, but the canonical email-to-dealer mapping lived in a separate MySQL view that the warehouse team used.
  • Tier prices were in catalog_product_entity_tier_price, but 2,300 dealer overrides lived in a custom table called nl_partner_priceset that no one had touched since 2019.
  • Orders were in Magento, but a nightly cron rewrote line items into a flat CSV for Exact Globe.
  • Stock levels were pushed into Magento every hour by an ERP webhook, but the warehouse trusted its own count, not the website's.

We did not start the rebuild until that map was signed off. Two engineers, three days, 41 entities documented. Cheap.

Week 1: the shadow stack

We provisioned the target environment alongside production, not as a replacement. Nothing replaced anything yet. The dealers were still hitting the old Magento.

The choices that mattered:

  • Medusa.js for the commerce core. Headless, TypeScript, customer and product and order primitives we could extend without forking. We had built two B2B portals on it the previous year and trusted the seams.
  • Remix for the storefront. Server rendering matters when dealers log in from warehouse iPads on 4G. The route-based loaders let us run the dealer-pricing check at the edge.
  • Postgres 15 instead of MySQL. The price-agreement model needed window functions and range types we didn't want to fake.

The shadow database mirrored production over a one-way logical replication slot from MySQL. Every customer_entity insert showed up in Postgres within seconds, transformed by a Node worker into the new schema. If the worker fell behind, we paged. The lag dashboard ran on the same Grafana the warehouse already used.

Week 2: price agreements as the source of truth

The 18,400 dealer-tier prices were the single most fragile thing in the whole system. Each agreement carried a customer ID, a SKU pattern, a quantity break, a percentage discount, and a contract end date. Some had been hand-edited in production in 2017 and never reflected back into the ERP.

We did not try to model this in Medusa's price-list primitives. They didn't fit. Instead we built a separate price_agreement table with a Postgres exclusion constraint to prevent overlapping contracts for the same dealer-SKU pair.

CREATE TABLE price_agreement (
  id            UUID PRIMARY KEY,
  customer_id   UUID NOT NULL REFERENCES customer(id),
  sku_pattern   TEXT NOT NULL,
  qty_min       INT  NOT NULL DEFAULT 1,
  discount_pct  NUMERIC(5,2) NOT NULL,
  valid_during  TSTZRANGE NOT NULL,
  EXCLUDE USING gist (
    customer_id WITH =,
    sku_pattern WITH =,
    valid_during WITH &&
  )
);

The exclusion constraint caught 47 overlapping rows during the first import. Every one of them was a real data bug in production. The ops lead spent a day cleaning them up by hand. The team never knew those overlaps existed because Magento's UI silently picked the lower price and moved on.

If your migration's first import is clean, you have not validated enough. Real production data has rot. Surface it before cutover, not after.

Week 3: the Exact Globe ledger sync

Exact Globe is the part nobody likes touching. It runs on Windows, talks SQL Server, and exposes a SOAP-ish XML API that pretends to be REST. The nightly sync had been a Python script on a hosting VPS since 2014, run at 02:15 because someone once said it had to be after the warehouse export.

We rewrote it as a Medusa worker job using the same XML envelope as the old script. Identical fields, identical order, identical line breaks. Bit for bit. The Exact administrator had a parser that hated whitespace changes and we were not the ones to test that on a Friday.

For the cutover we ran both syncs in parallel for fourteen nights. The old script wrote to the live Exact instance. The new worker wrote to a sandbox database the Exact admin had spun up. Every morning at 09:00 a diff job ran across the two ledgers and posted any deltas to a Slack channel only three people could see.

Twelve nights, zero deltas. On night thirteen we found a rounding difference on quantity-2 lines: the old script rounded after tax, the new one before. We matched the old behaviour and ran two more clean nights.

Week 4: shadow traffic mirroring

This is the week most migrations skip. They shouldn't.

We sat a small Go proxy in front of the load balancer. Every incoming request to the Magento portal was duplicated, in real time, to the new Medusa + Remix stack. The duplicate response was thrown away. Only the live Magento response went back to the dealer.

What we measured from the shadow:

  • Response times. The Remix product pages were rendering at p95 240ms. Magento sat at 1.4s. Predictable.
  • Diff'd outputs. A separate worker compared the dealer-tier price computed by each stack for every request. Day one showed a 4% mismatch rate. By the end of the week, after fixing two pattern-matching bugs in the SKU wildcard handler, we were at 0.02%.
  • Error budgets. The new stack threw a 500 on PATCH requests with no Content-Length. Real Magento accepted those silently. We added the same tolerance and moved on.

None of this work would have been visible from a staging environment. Real dealer traffic exposes the assumptions you didn't know you were making.

Week 5: the cutover and the deletion problem

The cutover itself was anticlimactic. We flipped the load balancer at 04:00 on a Tuesday. The shadow stack became the live stack. The old Magento went read-only and stayed up for ten more days as a rollback target.

One thing we did not plan well: archiving the old order history.

We had decided to keep nine years of Magento orders in a magento_legacy_orders table in Postgres, accessible via a single admin view for compliance. After cutover we wanted to prune anything older than seven years. There were six million rows.

This is where a recent thread on Postgres deletes (titled, accurately, that the only scalable delete in Postgres is DROP TABLE) earned its front-page slot. A naive DELETE on six million rows with foreign keys was going to lock our admin view for fifteen minutes during business hours. We did not want fifteen minutes of incident in week five.

What we did instead:

  1. Range-partitioned the legacy table by year at import time. Nine partitions, one per year.
  2. Compliance retention only needed the last seven. To prune, we did not DELETE. We detached and dropped the two oldest partitions.
  3. The whole operation took under a second and held no row locks.
ALTER TABLE magento_legacy_orders
  DETACH PARTITION magento_legacy_orders_2014;

DROP TABLE magento_legacy_orders_2014;

If you ever expect to delete a large slice of historical data, partition it the day you import it. By the time you need to prune, your only good option is to drop the partition.

Warning

Magento 1 reached end-of-life on 30 June 2020. Every PCI scan after that date is a compliance question, not a technical one. Card processors will eventually notice. Adobe stopped patching it, and so did everyone else.

What we would do differently

Three things.

First, we underestimated how long the Exact Globe parser needed in parallel. Two weeks was enough. We'd plan three next time. The asymmetry is that one missed ledger night is a real accounting problem.

Second, we should have shipped the diff dashboard to the operations lead in week two, not week four. She caught two real pricing bugs in the first hour she had access to it. Engineers were guarding the data and they should not have been.

Third, the rollback plan was a flag in the load balancer. It worked, but it would not have helped if the cutover broke the Exact sync in a way that didn't show up until the next morning. We now require a 24-hour ledger-diff window on top of any cutover before the rollback target gets retired.

The pattern, in five lines

If you skim this post for next week:

  1. Map every entity and its real source of truth before you write code.
  2. Run the new stack alongside the old with shadow traffic for at least seven days, including a weekend and a month-end.
  3. Diff the outputs continuously and surface deltas to a non-engineer.
  4. Run any ledger sync in parallel for two weeks minimum.
  5. Partition any table you might ever need to delete from.

When we ran this for the Enschede distributor, cutover Tuesday had zero support tickets. The CFO noticed because the warehouse reports were two seconds faster, not because the website had changed. That is the result we look for on any legacy migration: the work was hard, but the day the dealers logged in, nothing felt different except the speed.

Five-minute action: open your production database, find your largest order or invoice table, and check whether it is partitioned. If it isn't and it ever will be, that is the smallest piece of debt you can pay down this week.

Key takeaway

Map every entity to its real source of truth before you write a line of code. The map is week zero of any clean cutover.

FAQ

Why not just upgrade to Magento 2?

For a 28-person distributor with heavy customisation, Magento 2's licensing, hosting cost, and rewrite scope often exceed a clean rebuild on a headless stack. We weigh both and pick by total cost over three years.

How long does the shadow traffic period need to run?

Minimum seven calendar days, including a weekend and a month-end. For portals with a nightly ledger sync, fourteen nights is safer. We have never regretted running it longer.

What about SEO during the cutover?

We pre-build the URL map and 301-redirect every old Magento route to the new Remix route at the proxy layer. We watch Search Console for 30 days and fix any 404 spikes the same day.

Does Medusa.js handle B2B pricing out of the box?

Partly. Tier and customer-group pricing work. Anything more complex (contract overrides, range constraints, overlapping agreements) usually needs a dedicated table outside the price-list primitive.

magentomigrationphplegacy sitese-commercearchitecture

Building something?

Start a project