Two shoppers type “jacket” into the search bar. Until late September, they saw the same products in the same order. One had added three black blazers to her wishlist the day before, the other only ever looks at leather jackets: the engine had no idea.
On 29 September 2026 at 14:11 UTC, we switched Amazon Personalize on across the site’s nine storefronts. It reorders the first page of search results and the product grid on category pages, based on what each visitor has viewed, added to their bag, saved or bought. Not everywhere, not for everyone: in one arm of an A/B test, against our current merchandising tool.
What follows isn’t a machine learning showcase. It’s the story of an integration where almost every decision was about taking something away: an SDK, data, campaigns, a field. And of one rule held all the way through: Personalize can improve the order of a page, it can never stop the page from showing.
Why a custom recipe
By default, AWS offers ready-made e-commerce “recommenders”. We set them aside for a simple reason: none of them can reorder a list you hand it. And both of our surfaces, search and the category grid, are exactly that. The search engine or the catalogue decides which products are relevant and in stock; personalisation should only decide the order.
That’s the job of the Personalized-Ranking-v2 recipe: we give it up to a hundred product IDs and a visitor, and it returns them sorted for that visitor. It never suggests a product that isn’t in the list. For a fashion site that’s a valuable guarantee: personalisation can’t push a sold-out item to the top, or a product from another category.
Interactions go through us
The first architectural decision was to put no AWS SDK in the browser. Interactions go to Helios, our catalogue API, which validates them and forwards them to Personalize. The browser holds no AWS credentials, and it’s our code, not a third-party script, that decides what leaves.
The contract is deliberately narrow. Seven interaction types, no more: product page view, click in a list, add to bag, wishlist, back-in-stock alert, virtual try-on and purchase. Helios’s Zod schema is strict, and each optional field is only accepted on the interaction that carries it:
const eventSchema = z.object({
eventType: z.enum(['view', 'select', 'add-to-cart', 'wishlist',
'purchase', 'try-on', 'stock-alert']),
itemId: z.string().regex(/^[A-Z0-9_]{4,40}$/),
visitorId: z.string().min(8).max(64),
userId: universalId,
storeCode: z.enum(['fr_fr', ...Object.keys(storeConfig)]),
eventValue: z.number().nonnegative().max(100_000).optional(),
orderId: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/).optional(),
}).strict(); // + a superRefine: eventValue and orderId only on purchase
A purchase carries its order number, which acts as a replay key: reloading a confirmation page doesn’t count two purchases. Its value is computed by Magento, in euros, never by the front end.
Consent is handled two ways, depending on what’s at stake. Sending interactions fails closed: until the visitor has accepted the measurement purpose in the consent banner, nothing leaves. Display doesn’t depend on consent: a visitor who declines simply sees the page in the engine’s order, without personalisation.
Alongside the live stream, Magento exports every night what the browser can’t tell: each market’s catalogue, customer profiles (including sizes, derived from orders) and purchase history. Personalize imports those files, then trains.
Ten slots, not one more
Personalize caps at ten the total of distinct interaction types plus optional fields in the interactions dataset. A detail the documentation barely mentions: the event type and the event value count towards that total themselves. With three fields (type, value, country) and seven interaction types, we’re at exactly ten.
That constraint settled several debates. An “on sale” field was planned: it went over the limit, it was the only one that forced a full rebuild of the dataset, and it was never passed at recommendation time. It’s gone. Adding an interaction tomorrow, search for instance, will mean dropping another one.
Each interaction has a weight, which tells the model what to predict. We ordered them by the probability, measured on our own data, that an interaction is followed by a purchase of the same product:
| Interaction | Weight |
|---|---|
| Purchase | 1 |
| Add to bag | 0.6 |
| Back-in-stock alert | 0.5 |
| Virtual try-on | 0.5 |
| Wishlist | 0.35 |
| Product page view | 0.1 |
| Click in a list | 0.05 |
The previous weighting put the wishlist ahead of the bag, on the grounds that it’s rarer. That confused rarity with signal strength: the data says the opposite.
Less data, better model
The first bootstrap held tens of millions of historical anonymous views from our analytics tool. We assumed more history would make a better model. We checked before reloading it, with a simple test: predict the next product page a visitor will view.
| Data used | Right product in the top 10 |
|---|---|
| Co-views over the last 30 days | 40.6% |
| Anonymous history alone | 25.5% |
| Both mixed | 37.6% |
In fashion, recent browsing beats volume. The old history didn’t just make every training run heavier, since Personalize bills training by the number of interactions: it made predictions worse. We didn’t reload it.
Anonymous visitors aren’t forgotten, though. Their interactions are sent live under a visitor ID, which adapts the order during the visit. And the day they sign in, one interaction carries both the visitor ID and the customer ID: at the next full training, Personalize attaches the earlier browsing to the customer. On the customer side we use the universal ID shared with our CRM rather than Magento’s internal ID, so a customer stays the same person from one system to the next.
Shadow first, then a single campaign
We switched on in two steps. On 28 September at 14:22 UTC, shadow mode started on all nine storefronts: interactions flow, Personalize learns, but nothing it computes is shown. That day we checked that events arrived without errors, that the catalogue was fresh, and that the first model held up on its offline metrics.
The next day, once the first automatic training had finished, we opened search and the grid in the Personalize arm of the test. The switches live in the Magento back office, per storefront, and reach the site’s servers without a redeploy, through the mechanism described in our article on per-market feature flags.
Only one campaign runs: ranking. In Personalize, a campaign only serves live requests; collection, imports and training don’t need one. The “similar products” and “for you” recommendation models keep training, with no campaign and therefore no serving cost. The day their surface opens, a campaign can be recreated in fifteen minutes.
The ranking model retrains every day. This recipe doesn’t update itself and can’t rank a product that has no interactions yet: daily training brings in new arrivals as soon as they get their first views.
Reordering without breaking anything
On every page view, the site asks Helios to reorder the list the engine produced. That’s where the opening rule turns into code.
On the search side, page rendering waits for Helios for at most 250 milliseconds; Helios itself gives Personalize 800 milliseconds and a single attempt. No identity, monthly budget reached, timeout, error: the page keeps the engine’s order. Every abstention is logged with its reason, because a test arm quietly degraded looks exactly like a model that underperforms.
Three details took more thought than everything else.
New arrivals keep their slot. Personalized-Ranking-v2 returns the products it doesn’t know yet unscored, after all the others. Applied as is, that would send every new arrival to the bottom of the page on the day it lands, exactly the opposite of what a fashion brand wants. So Helios puts each unscored product back where the engine had placed it, and only shuffles scored products among themselves:
export const keepUnscoredInPlace = (
ranking: { itemId?: string; score?: number }[] | undefined,
candidates: string[],
): string[] => {
const returned = new Map((ranking ?? []).filter((i) => i.itemId)
.map((i) => [i.itemId as string, i.score]));
const scored = [...returned].filter(([, score]) => score !== undefined)
.map(([sku]) => sku);
return candidates
.filter((sku) => returned.has(sku))
.map((sku) => (returned.get(sku) === undefined ? sku : scored.shift() as string));
};
A list doesn’t move under your fingers. The ranking is cached per visitor. For search, the cache key changes with every new interaction, so the order follows intent. For a category grid, the key is the visit to that list: what you do while scrolling it doesn’t reshuffle it as you go.
Products already bought move to the end of the grid, without disappearing. We use a filter dedicated to purchases alone. The generic recommendation filter would also have dropped products the catalogue export hasn’t seen yet, in other words new arrivals. With the dedicated filter, a product missing from the response can only be one that was bought: Helios puts it back at the end, and the page stays complete. And if the filter fails, the ranking is served without demotion rather than not at all.
What we measure
The test compares two pairs: our current search engine and merchandising tool, against Meilisearch with Personalize. Measuring the pair is deliberate: it’s the setup we’d put in production, and the Personalize arm reorders precisely Meilisearch’s results.
Every click in the results records whether the order shown came from Personalize or from the engine, which gives the real coverage of the re-ranking, not just its intent. The test runs outside sale periods, and it takes about a month of traffic to detect a 10% lift in conversion among visitors who search. The results aren’t in yet: we’ll come back to it when they say something.
Recommendation blocks stay on our current tool for now. They’ll open one at a time, each with its own test.
What we learned
- Removing is the most profitable move. No SDK in the browser, no old history, no idle campaigns, no extra field. Every removal simplified the system and improved its quality or its cost.
- Test your intuitions on your own data. “More history, better model” seemed obvious. A one-day offline test showed the opposite.
- Personalisation is a bonus, never a dependency. Short timeout, single attempt, silent but logged abstention: the page always shows, with or without the model.
- The devil is in the business edge cases. Buried new arrivals or products moving while you scroll would have ruined the experience without ever showing up in a training metric.
- Shadow before spotlight. A day of collection with no visible effect let us validate the flows, the catalogue and the first model before a single shopper was exposed.