<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ohad Farkash</title>
    <description>The latest articles on DEV Community by Ohad Farkash (@ohadfarkash).</description>
    <link>https://dev.to/ohadfarkash</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4089718%2F8da40890-910d-4279-966d-5b2a14b7254c.png</url>
      <title>DEV Community: Ohad Farkash</title>
      <link>https://dev.to/ohadfarkash</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ohadfarkash"/>
    <language>en</language>
    <item>
      <title>I Asked AI to Build a Shopping Basket Under a Hard Budget — Here's What Broke</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Fri, 28 Aug 2026 15:06:02 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-heres-what-broke-1e2f</link>
      <guid>https://dev.to/ohadfarkash/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-heres-what-broke-1e2f</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for a&lt;br&gt;
large marketplace. The last thing I shipped was a feature that sounds trivial and&lt;br&gt;
isn't: type a need and a &lt;em&gt;hard&lt;/em&gt; budget — "useful things for a big dog, 80 total" —&lt;br&gt;
and get back a real basket of real products that never goes a cent over.&lt;/p&gt;

&lt;p&gt;The word doing the work is &lt;em&gt;real&lt;/em&gt;. The model is not allowed to invent a single&lt;br&gt;
product, price, rating or shipping figure. Everything with a number attached comes&lt;br&gt;
from the marketplace API. The model only gets to do the one thing it's actually&lt;br&gt;
good at: understand what a human meant. Drawing that line is where all the&lt;br&gt;
interesting failures lived.&lt;/p&gt;

&lt;p&gt;Here's what broke.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the AI is actually allowed to do
&lt;/h2&gt;

&lt;p&gt;When someone types "useful things for a big dog, 80," the model does not return&lt;br&gt;
products. It returns a &lt;em&gt;plan&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"כלב"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Chew toy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dog chew toy"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Leash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"large dog leash"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Brush"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pet hair brush"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bowl"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nl"&gt;"kw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dog food bowl"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. A set of complementary &lt;em&gt;roles&lt;/em&gt; that together serve the need, each with&lt;br&gt;
a search keyword. No prices, no products, no ratings — the model never sees a&lt;br&gt;
catalogue. Every role keyword then goes to the real search endpoint, in parallel,&lt;br&gt;
and comes back with real listings: price, image, rating, order count, affiliate&lt;br&gt;
link. The AI understood the intent; the marketplace supplied the facts.&lt;/p&gt;

&lt;p&gt;This split is the whole design. The moment you let a language model emit a price&lt;br&gt;
or a product name, you've built a very confident fiction generator. Keep it on the&lt;br&gt;
intent side of the wall and it's genuinely useful.&lt;/p&gt;
&lt;h2&gt;
  
  
  The data that simply does not exist: shipping
&lt;/h2&gt;

&lt;p&gt;The feature has a switch: &lt;em&gt;does the budget include shipping?&lt;/em&gt; Honoring it turned&lt;br&gt;
out to be impossible in the obvious way, because &lt;strong&gt;the affiliate API does not&lt;br&gt;
return a shipping cost.&lt;/strong&gt; It returns an item price and a delivery time in &lt;em&gt;days&lt;/em&gt; —&lt;br&gt;
never a freight figure. There is no endpoint that gives you "this item ships to&lt;br&gt;
that country for X."&lt;/p&gt;

&lt;p&gt;I could have had the model estimate shipping. That's exactly the invention I'd&lt;br&gt;
banned. So the honest version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Include shipping"&lt;/strong&gt; → filter the search to free-shipping items only. Now
shipping is a &lt;em&gt;verified&lt;/em&gt; zero, not a guess, and the budget math stays true.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Products only"&lt;/strong&gt; → ignore shipping entirely and say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Currency was a smaller version of the same lesson: the marketplace converts prices&lt;br&gt;
server-side when you pass a target currency, so there is no live FX call to&lt;br&gt;
reconcile per item — you just work in one currency the whole way through.&lt;br&gt;
Coupons exist in the payload but change without notice, so they're surfaced as a&lt;br&gt;
caveat, never subtracted from the total. If a number can't be trusted, it doesn't&lt;br&gt;
get to move the budget.&lt;/p&gt;
&lt;h2&gt;
  
  
  Choosing items without going over: yes, it's Knapsack
&lt;/h2&gt;

&lt;p&gt;Once each role has a pool of real candidates, picking a combination that maximizes&lt;br&gt;
value without exceeding the budget is the &lt;a href="https://en.wikipedia.org/wiki/Knapsack_problem" rel="noopener noreferrer"&gt;0/1 Knapsack&lt;br&gt;
problem&lt;/a&gt; wearing a shopping hat.&lt;/p&gt;

&lt;p&gt;I didn't reach for a full dynamic-programming solution, for three reasons: the&lt;br&gt;
item count is small (a handful of roles, ~20 candidates each), the whole thing&lt;br&gt;
runs inside a request budget of a few seconds, and there's a constraint textbook&lt;br&gt;
knapsack doesn't have — &lt;em&gt;diversity&lt;/em&gt;. Two chew toys is not a good dog basket even&lt;br&gt;
if the numbers are optimal.&lt;/p&gt;

&lt;p&gt;So it's a greedy build with a fill pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. base: cheapest viable item per role, so every role is covered&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rolesByCheapest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;budget&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;basket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. fill: add new-role items (variety first), then upgrade to pricier/better&lt;/span&gt;
&lt;span class="c1"&gt;//    picks, until only a few units of budget remain&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;budget&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SLACK&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;basket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bestAffordableNewRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// prefer an unused role&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bestUpgradeThatUsesBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// else spend up on a better item&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;up&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The base pass guarantees coverage. The fill pass is what makes the basket actually&lt;br&gt;
&lt;em&gt;feel&lt;/em&gt; like the budget you asked for — which brings me to the bug that embarrassed&lt;br&gt;
me most.&lt;/p&gt;
&lt;h2&gt;
  
  
  The basket that spent 36 of a 200 budget
&lt;/h2&gt;

&lt;p&gt;Early on, a "cosmetics basket, 200" came back at 36. Technically valid — every&lt;br&gt;
item real, under budget, nothing invented. Practically useless. A customer asking&lt;br&gt;
for a 200 basket and getting 36 worth of stuff feels short-changed, not thrifty.&lt;/p&gt;

&lt;p&gt;The cause was the value function. "Quality first" scored items by rating and&lt;br&gt;
sales, which has no opinion about &lt;em&gt;using the budget&lt;/em&gt;. It happily picked one cheap,&lt;br&gt;
well-rated item per role and stopped. The fix was two-part: scale the number of&lt;br&gt;
roles with the budget (a 200 cosmetics basket wants 6–9 item types, not 3), and&lt;br&gt;
add the fill loop above, which explicitly targets a near-full budget. Cosmetics at&lt;br&gt;
200 now lands at ~199. Never over — that constraint is absolute — but close enough&lt;br&gt;
that the number you typed is the number you get.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug that made it look like a scam
&lt;/h2&gt;

&lt;p&gt;The one that actually scared me: a &lt;strong&gt;nail-polish basket returned a women's coat&lt;/strong&gt;&lt;br&gt;
for 80. Nothing about a coat belongs in a nail order.&lt;/p&gt;

&lt;p&gt;Root cause was a relevance shortcut. Each role carried a "must contain" keyword to&lt;br&gt;
filter noise, and the role &lt;em&gt;top coat&lt;/em&gt; had contributed &lt;code&gt;coat&lt;/code&gt;. A listing titled&lt;br&gt;
"Women Suede Coat" matched &lt;code&gt;coat&lt;/code&gt; and sailed through. A generic word from one role&lt;br&gt;
had opened the door to a completely different category.&lt;/p&gt;

&lt;p&gt;The fix was to stop filtering per role and filter per &lt;em&gt;basket&lt;/em&gt;. The planner now&lt;br&gt;
returns a &lt;code&gt;domain&lt;/code&gt; — a few need-specific stems, in every language the title might&lt;br&gt;
be in — and &lt;strong&gt;every&lt;/strong&gt; item, whatever its role, must contain one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;polish&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manicure&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ציפורנ&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// for "nail polish"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;relevant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stem&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;titleLocal&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A coat contains none of &lt;code&gt;nail / polish / manicure&lt;/code&gt;, so it's gone — regardless of&lt;br&gt;
which role's keyword it happened to match. Precision beat recall here on purpose:&lt;br&gt;
in a basket, one off-topic item reads as "this thing is broken," and I'd rather&lt;br&gt;
drop a borderline product than ship the coat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed beat correctness. Again.
&lt;/h2&gt;

&lt;p&gt;The first working version took 10–16 seconds cold: one planning call to the model,&lt;br&gt;
then N marketplace searches. Users don't wait 15 seconds for a basket; they leave.&lt;/p&gt;

&lt;p&gt;Two things fixed it. The searches were already cached, so the second time anyone&lt;br&gt;
builds a similar basket the role searches are warm. And the &lt;em&gt;plan&lt;/em&gt; — the roles for&lt;br&gt;
a given need and budget band — is cacheable too, and priority-independent, so I&lt;br&gt;
cache it and skip the model entirely on a repeat. A brand-new query is still&lt;br&gt;
~10s (N cold marketplace round-trips are the floor), but a repeat is &lt;strong&gt;0.4s&lt;/strong&gt;. As&lt;br&gt;
the cache warms across users, more baskets land in the fast path. Same lesson I&lt;br&gt;
keep relearning: a correct answer that arrives too late is a wrong answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when a price changes after you build the basket
&lt;/h2&gt;

&lt;p&gt;It will. Prices and stock on a live marketplace move by the hour. The basket you&lt;br&gt;
show is a snapshot, and pretending otherwise is the same sin as inventing a&lt;br&gt;
shipping figure. So the total is computed server-side from the freshest search at&lt;br&gt;
build time, the items carry the marketplace's own "prices may change" caveat, and&lt;br&gt;
the buy links go straight to the live listing where the real, current price is&lt;br&gt;
authoritative. The basket is a &lt;em&gt;starting point that respects your budget&lt;/em&gt;, not a&lt;br&gt;
locked quote — and it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern underneath all of it
&lt;/h2&gt;

&lt;p&gt;Every one of these fixes is the same move: &lt;strong&gt;let the model interpret, never let it&lt;br&gt;
assert.&lt;/strong&gt; It's brilliant at turning "stuff for a big dog, 80" into search terms and&lt;br&gt;
domain anchors. It's a liability the instant it emits a price. Keep the language&lt;br&gt;
work and the truth work on opposite sides of a hard wall, and the failures stop&lt;br&gt;
being "the AI hallucinated" and start being ordinary, fixable engineering — a&lt;br&gt;
missing field, a too-greedy heuristic, a generic keyword that matched the wrong&lt;br&gt;
thing.&lt;/p&gt;

&lt;p&gt;The budget basket that came out of it is live on OneFindMe if you want to see the&lt;br&gt;
shape of it. But the interesting part was never the demo. It was the wall.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Localizing a marketplace search for 12 markets: the assumptions that cost me</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:19:24 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/localizing-a-marketplace-search-for-12-markets-the-assumptions-that-cost-me-3a9j</link>
      <guid>https://dev.to/ohadfarkash/localizing-a-marketplace-search-for-12-markets-the-assumptions-that-cost-me-3a9j</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for&lt;br&gt;
AliExpress in 12 languages. Translating the UI was the easy part. The hard part&lt;br&gt;
was everything I &lt;em&gt;assumed&lt;/em&gt; about what each market wants — and kept getting wrong&lt;br&gt;
until I measured instead.&lt;/p&gt;

&lt;p&gt;This is three of those assumptions, what the data actually said, and the code&lt;br&gt;
that changed. If you're localizing anything commerce-shaped past the string&lt;br&gt;
table, you'll recognize the pattern: the bug is never the translation, it's the&lt;br&gt;
belief underneath it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Assumption 1: "same homepage, translated, is localized"
&lt;/h2&gt;

&lt;p&gt;The first version showed every market the same "today's deals" row — the same&lt;br&gt;
eight products, just translated. A shopper in Saudi Arabia and a shopper in&lt;br&gt;
Brazil saw identical items in different words.&lt;/p&gt;

&lt;p&gt;That isn't localization, it's translation wearing a localization costume. The&lt;br&gt;
Gulf shopper doesn't want the same products as the Brazilian one; they want&lt;br&gt;
things that sell &lt;em&gt;there&lt;/em&gt; — an abaya, an oud diffuser, an Arabic coffee pot.&lt;br&gt;
Showing them a translated version of someone else's shortlist is worse than&lt;br&gt;
showing nothing, because it signals the site doesn't actually know their market.&lt;/p&gt;

&lt;p&gt;The fix was per-market seed lists, chosen by &lt;strong&gt;audience before geography&lt;/strong&gt;. A&lt;br&gt;
single dispatch, checked in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dealsTerms&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ARAB_COUNTRIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_GULF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_IL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;EU_COUNTRIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_EU&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;DEALS_INTL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Language is checked &lt;strong&gt;before&lt;/strong&gt; country on purpose: an Arabic speaker who happens&lt;br&gt;
to be browsing from Germany should still get the Gulf row, not the EU one. The&lt;br&gt;
person's language is a stronger signal of what they're shopping for than the IP&lt;br&gt;
they happen to be behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; a translated shortlist is not a localized shortlist. Localize the&lt;br&gt;
&lt;em&gt;selection&lt;/em&gt;, not just the labels — and let the audience signal win over the geo&lt;br&gt;
signal when they disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assumption 2: I knew what a market considered acceptable
&lt;/h2&gt;

&lt;p&gt;Here's the one I'm least proud of, and the most useful.&lt;/p&gt;

&lt;p&gt;A women's-clothing search on the Arabic surface returned some items I looked at&lt;br&gt;
and thought: &lt;em&gt;I should filter these out for a conservative market.&lt;/em&gt; I started&lt;br&gt;
writing a modesty filter — block anything that looked immodest on the Arabic&lt;br&gt;
side. It felt responsible.&lt;/p&gt;

&lt;p&gt;Then I did the thing I should have done first: I opened the actual AliExpress&lt;br&gt;
Saudi storefront, as a Saudi user, and ran the same query. &lt;strong&gt;AliExpress itself&lt;br&gt;
puts "sexy"-labelled and form-fitting clothing on the first page of a plain&lt;br&gt;
dress search there.&lt;/strong&gt; Immodest clothing is the market norm, not an edge case.&lt;/p&gt;

&lt;p&gt;My filter would have made my engine &lt;em&gt;stricter than the store the shopper was&lt;br&gt;
walking into anyway&lt;/em&gt; — blocking a sports bra from someone who searched for&lt;br&gt;
women's clothing, while the destination site shows it on page one. That's not&lt;br&gt;
protecting anyone; it's just lost results.&lt;/p&gt;

&lt;p&gt;So I threw the modesty filter away. What I kept was much narrower: a filter for&lt;br&gt;
things that were a &lt;strong&gt;relevance&lt;/strong&gt; failure in any market — a fetish costume&lt;br&gt;
surfacing on a search for "dress" is wrong for a shopper in Riyadh &lt;em&gt;and&lt;/em&gt; one in&lt;br&gt;
Tel Aviv. That list is short and explicit, and deliberately excludes words with&lt;br&gt;
innocent uses (a "nightclub dress" is an ordinary party dress; "Lolita" is a&lt;br&gt;
real fully-covering fashion style; "sexy" is 42% of AliExpress's own first&lt;br&gt;
page).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; don't encode your assumption about a market into a filter. Go&lt;br&gt;
observe the market — the real benchmark for what to show is the store the user&lt;br&gt;
is heading to. I almost shipped a paternalistic filter built on a guess; four&lt;br&gt;
minutes of looking inverted the whole decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assumption 3: more traffic in a language means demand in that language
&lt;/h2&gt;

&lt;p&gt;I'd built full localized pages for three Gulf markets, confident all three had&lt;br&gt;
Arabic shopping demand. Then I pulled the search-console data per country, and&lt;br&gt;
two of the three had &lt;strong&gt;almost no Arabic queries at all&lt;/strong&gt; — the "traffic" was&lt;br&gt;
my own users mis-geolocated through carrier routing and VPNs, searching in other&lt;br&gt;
languages entirely.&lt;/p&gt;

&lt;p&gt;I'd spent weeks building for demand that a five-minute export would have shown&lt;br&gt;
me wasn't there. Worse: content I'd written as filler in one language quietly&lt;br&gt;
out-performed the pages I'd carefully localized, because that's where the real&lt;br&gt;
demand was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "traffic from country X" and "demand in language X" are different&lt;br&gt;
measurements, and you can't tell them apart without looking at the actual&lt;br&gt;
queries. Export the per-market data &lt;em&gt;before&lt;/em&gt; you build the per-market page, not&lt;br&gt;
after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern under all three
&lt;/h2&gt;

&lt;p&gt;Every one of these was the same shape: a reasonable-sounding assumption about a&lt;br&gt;
market I wasn't in, encoded into code, that a small measurement would have&lt;br&gt;
corrected before I wrote a line. Translating strings is a solved problem.&lt;br&gt;
Localizing &lt;em&gt;judgment&lt;/em&gt; — what to show, what to filter, what to build — is where&lt;br&gt;
the real work is, and it's all measurement, not intuition.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Localize the selection, not just the strings.&lt;/strong&gt; A translated shortlist isn't
local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the audience signal beat the geo signal&lt;/strong&gt; when they conflict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observe the market before you filter it.&lt;/strong&gt; The benchmark is the store the
user is going to, not your idea of that market.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-market demand data before per-market pages.&lt;/strong&gt; Traffic ≠ demand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building commerce in markets you don't personally live in, the&lt;br&gt;
uncomfortable truth is that your instincts about those markets are a liability&lt;br&gt;
until they're checked. Mine were wrong three times in a row. Curious whether&lt;br&gt;
anyone's found a faster way to catch these than shipping and measuring — the&lt;br&gt;
comments are open.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — AI product search for&lt;br&gt;
AliExpress by text or image, in 12 languages. It's free; it runs on affiliate&lt;br&gt;
commission at no extra cost to the buyer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>i18n</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building a 12-language AI product search on the edge: what actually broke</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Tue, 25 Aug 2026 10:10:26 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/building-a-12-language-ai-product-search-on-the-edge-what-actually-broke-45kb</link>
      <guid>https://dev.to/ohadfarkash/building-a-12-language-ai-product-search-on-the-edge-what-actually-broke-45kb</guid>
      <description>&lt;p&gt;I run &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt;, an AI product-search front end for&lt;br&gt;
AliExpress. You describe what you want in plain language — in any of 12 languages&lt;br&gt;
— or upload a photo, and it returns the product, similar items, and cheaper&lt;br&gt;
alternatives. It runs entirely on a Cloudflare Worker with an LLM doing the&lt;br&gt;
language work.&lt;/p&gt;

&lt;p&gt;This isn't a launch post. It's the three problems that were genuinely hard, the&lt;br&gt;
wrong first solutions I shipped, and what actually fixed them. If you're putting&lt;br&gt;
an LLM in front of a marketplace search API, you'll hit all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack, briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers&lt;/strong&gt; for the whole API — search, translation, image
understanding, caching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku&lt;/strong&gt; for query translation and image identification. I tried Sonnet too, but for this task — short product names and image labels — Haiku was actually the better fit, and it's far cheaper when every search is an LLM call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workers KV&lt;/strong&gt; as the cache layer.&lt;/li&gt;
&lt;li&gt;A static multilingual front end on Cloudflare Pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core loop is: take a natural-language query in any language → turn it into a&lt;br&gt;
clean marketplace search term → hit the affiliate search API → rank and filter →&lt;br&gt;
return. The interesting failures are all in the "turn it into a clean search&lt;br&gt;
term" step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 1: the model translated &lt;em&gt;too well&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The first version asked the model to "translate this shopping query to English."&lt;br&gt;
It did — beautifully, fluently, and uselessly.&lt;/p&gt;

&lt;p&gt;A user searching for a &lt;code&gt;שמלת ערב&lt;/code&gt; (evening dress) got back&lt;br&gt;
&lt;code&gt;an elegant formal gown suitable for evening occasions&lt;/code&gt;. Grammatically perfect.&lt;br&gt;
It also returned almost nothing from the marketplace, because &lt;strong&gt;nobody titles a&lt;br&gt;
product listing in fluent prose.&lt;/strong&gt; Marketplace sellers write&lt;br&gt;
&lt;code&gt;Women Elegant Evening Party Dress Sexy Backless&lt;/code&gt; — keyword soup, not sentences.&lt;/p&gt;

&lt;p&gt;The fix was to stop asking for translation and start asking for &lt;strong&gt;the 2-3 word&lt;br&gt;
noun phrase a seller would put in a title.&lt;/strong&gt; The prompt changed from "translate"&lt;br&gt;
to "return the short product name an AliExpress seller would use." Fluency was&lt;br&gt;
the enemy; the model's instinct to produce natural language was exactly wrong for&lt;br&gt;
a keyword search index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when an LLM feeds a keyword system, you don't want its best language.&lt;br&gt;
You want the language of the target index. Prompt for that explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: the model invented category IDs, and they outranked reality
&lt;/h2&gt;

&lt;p&gt;To narrow results, I let the model suggest an AliExpress category ID alongside&lt;br&gt;
the keywords. Category-constrained search returns cleaner results — when the ID&lt;br&gt;
is real.&lt;/p&gt;

&lt;p&gt;The model would confidently return category IDs that &lt;strong&gt;did not exist.&lt;/strong&gt; Not&lt;br&gt;
often, but often enough. And a nonexistent category ID doesn't error — it returns&lt;br&gt;
an empty or garbage result set, which then &lt;em&gt;replaced&lt;/em&gt; the perfectly good&lt;br&gt;
keyword-only results the same query would have produced. The hallucinated&lt;br&gt;
constraint silently beat the honest fallback.&lt;/p&gt;

&lt;p&gt;Two things fixed it. First, a hard allow-list: category IDs the model proposes&lt;br&gt;
are checked against a map of known-good IDs and dropped if unrecognised. Second,&lt;br&gt;
and more important, the keyword search always runs; the category is an&lt;br&gt;
&lt;em&gt;optional&lt;/em&gt; refinement layered on top, never a replacement. If the category path&lt;br&gt;
returns nothing, the keyword results are still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never let a model's optional enrichment silently override your&lt;br&gt;
deterministic baseline. Layer it, gate it, and make the baseline win by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: cold search was 6-8 seconds, and that was the whole business
&lt;/h2&gt;

&lt;p&gt;An uncached search does real work: an LLM call to build the query, the&lt;br&gt;
marketplace API round trip, ranking, filtering. Cold, that's 6-8 seconds. Users&lt;br&gt;
don't wait 6-8 seconds. The single biggest driver of bounce wasn't relevance —&lt;br&gt;
it was latency on the first search.&lt;/p&gt;

&lt;p&gt;The cache helps enormously: every search result is cached in KV for up to 30&lt;br&gt;
days, so a warm search returns in ~200 ms. But you can't cache a query nobody has&lt;br&gt;
run yet, and the &lt;em&gt;first&lt;/em&gt; person to search a term pays the full cost.&lt;/p&gt;

&lt;p&gt;Two moves cut the &lt;em&gt;perceived&lt;/em&gt; wait to near zero without making the search&lt;br&gt;
actually faster:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant bestsellers.&lt;/strong&gt; The moment a search starts, the UI shows a row of
known-good bestseller results for the category while the real search runs
behind it. The screen is never empty; the real results swap in when ready.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-warming on unknown terms.&lt;/strong&gt; When a new keyword is translated for the
first time, the translation is saved &lt;em&gt;and&lt;/em&gt; the search is pre-cached, so the
&lt;em&gt;next&lt;/em&gt; person to search that term — and there's almost always a next person —
gets the 200 ms warm path.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither makes the cold path faster. Both make it invisible. That distinction —&lt;br&gt;
optimising perceived latency instead of actual latency — moved the metric that&lt;br&gt;
mattered more than any relevance tuning did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; on a search product, the empty-state-while-loading is a feature, not&lt;br&gt;
a gap. Show &lt;em&gt;something&lt;/em&gt; instantly and backfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one I'd warn you about hardest
&lt;/h2&gt;

&lt;p&gt;A subtle one, because it looks like success: &lt;strong&gt;don't trust the marketplace's own&lt;br&gt;
"is this product available" signal in isolation.&lt;/strong&gt; The affiliate API would report&lt;br&gt;
live, purchasable products as gone. Filtering on it alone silently emptied result&lt;br&gt;
pages that should have been full. Availability needs corroboration, not a single&lt;br&gt;
boolean — the same lesson as the hallucinated category, in a different costume:&lt;br&gt;
one unreliable signal shouldn't be allowed to zero out a good result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself at the start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt for the target system's language, not the user's.&lt;/strong&gt; A keyword index
wants keywords, not prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model's optional output must never override your deterministic path.&lt;/strong&gt;
Gate it against known-good values; layer it; let the baseline win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimise perceived latency first.&lt;/strong&gt; Instant partial results beat a faster
spinner every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One signal should never zero out a result set.&lt;/strong&gt; Corroborate before you
filter to empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine runs in 12 languages now, and every one of those bugs showed up&lt;br&gt;
identically in each. If you're building anything that puts an LLM between a human&lt;br&gt;
sentence and a structured search index, you'll meet all four. Happy to compare&lt;br&gt;
notes in the comments — especially if you've found a better answer to the&lt;br&gt;
cold-search problem than "show bestsellers and pray."&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://onefindme.com/en/" rel="noopener noreferrer"&gt;OneFindMe&lt;/a&gt; — AI product search for&lt;br&gt;
AliExpress by text or image, in 12 languages. It's free; it runs on affiliate&lt;br&gt;
commission at no extra cost to the buyer.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>cloudflare</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The multilingual bugs that never throw: hreflang, JSON-LD and a site in 12 languages</title>
      <dc:creator>Ohad Farkash</dc:creator>
      <pubDate>Sat, 22 Aug 2026 12:47:29 +0000</pubDate>
      <link>https://dev.to/ohadfarkash/the-multilingual-bugs-that-never-throw-hreflang-json-ld-and-a-site-in-12-languages-50d2</link>
      <guid>https://dev.to/ohadfarkash/the-multilingual-bugs-that-never-throw-hreflang-json-ld-and-a-site-in-12-languages-50d2</guid>
      <description>&lt;p&gt;I run a search engine that publishes in twelve languages from one static site on Cloudflare Pages. Last week I audited its machine-readable layer — the part crawlers and answer engines read rather than humans — and found four problems.&lt;/p&gt;

&lt;p&gt;None of them threw an error. None appeared in logs. Every page rendered perfectly. That is the whole point of this post: the multilingual layer fails in a register where nothing tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The homepage was serving the wrong language to everyone abroad
&lt;/h2&gt;

&lt;p&gt;The site's primary market speaks Hebrew, so &lt;code&gt;/&lt;/code&gt; is Hebrew and &lt;code&gt;/en/&lt;/code&gt;, &lt;code&gt;/ar/&lt;/code&gt;, &lt;code&gt;/de/&lt;/code&gt; and nine others sit alongside it.&lt;/p&gt;

&lt;p&gt;A middleware rule redirected visitors from one specific region to their language. Everyone else — including every English speaker on earth — landed on Hebrew.&lt;/p&gt;

&lt;p&gt;My first instinct was to fix it with a broader geo-redirect: detect English-speaking countries, send them to &lt;code&gt;/en/&lt;/code&gt;. &lt;strong&gt;This would have been a bad idea, and it is worth saying why.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Googlebot crawls predominantly from US IPs. A geo-redirect on &lt;code&gt;/&lt;/code&gt; that keys off country would take the crawler off the Hebrew homepage and onto the English one almost every time it visited. You do not want your primary-market homepage to become the page the crawler can never reach.&lt;/p&gt;

&lt;p&gt;The correct tool is &lt;code&gt;hreflang&lt;/code&gt;, and it is what search engines built for exactly this. Checking the page, the tags were already there and already right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"he"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/en/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"ar"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/ar/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- …ten more… --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/en/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things make this work, and both are easy to get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The set must be reciprocal.&lt;/strong&gt; Every page in the group lists every other page &lt;em&gt;including itself&lt;/em&gt;. If &lt;code&gt;/en/&lt;/code&gt; does not point back at &lt;code&gt;/&lt;/code&gt;, search engines are entitled to ignore the whole cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;x-default&lt;/code&gt; is not "the default language" — it is the fallback for users you have no better match for.&lt;/strong&gt; Pointing it at the Hebrew homepage would have been the intuitive reading and the wrong one. It belongs on whichever version serves someone whose language you do not publish, which for most sites is English.&lt;/p&gt;

&lt;p&gt;With that in place, an English searcher gets &lt;code&gt;/en/&lt;/code&gt; from the search engine directly, and the crawler still sees the Hebrew homepage as the Hebrew homepage. No redirect needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The residual gap is worth naming honestly:&lt;/strong&gt; hreflang is a search-engine protocol. A crawler that simply fetches your bare domain and reads what comes back — which is what several AI crawlers do — still gets your primary language. There is no clean fix for that from inside hreflang. What I did instead was make sure the English URL is the one used everywhere off-site, in every directory listing and profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Structured data claimed eight languages; the site had twelve
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;WebApplication&lt;/code&gt; node carried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"inLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"he"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"ar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"ru"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"es"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"pt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"tr"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"fr"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four languages had been added since that array was written. Nobody updates a hand-maintained list in a JSON-LD blob, because nothing breaks when it goes stale. It just quietly asserts something untrue about your site, in the most machine-readable place on the page.&lt;/p&gt;

&lt;p&gt;If a value in your structured data duplicates a fact that lives elsewhere in your codebase — supported languages, prices, feature lists — either generate it from the source of truth or add an assertion. A test that reads the language directory listing and compares it to the array is about ten lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;sameAs&lt;/code&gt; is the entity-linking mechanism and mine was two years behind
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Organization.sameAs&lt;/code&gt; is how you tell a search engine "these profiles are the same entity as this site." Mine listed two profiles. Two more had been created and verified since, and neither was in the list.&lt;/p&gt;

&lt;p&gt;This is the same failure as the language array, with higher stakes: the whole value of building profiles elsewhere is that the site &lt;em&gt;claims&lt;/em&gt; them. Unclaimed profiles are just pages that happen to mention you.&lt;/p&gt;

&lt;p&gt;One judgement worth stating: I deliberately left out a directory listing that had been submitted but was still in a moderation queue, because its URL 404s until approval. &lt;strong&gt;A &lt;code&gt;sameAs&lt;/code&gt; pointing at a 404 is worse than an absent one&lt;/strong&gt; — you are asserting an identity link to a page that does not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The bug that nearly made me report the fix as a failure
&lt;/h2&gt;

&lt;p&gt;I updated &lt;code&gt;sameAs&lt;/code&gt; across the site with a scripted replacement, then wrote a verification pass to count how many nodes had changed.&lt;/p&gt;

&lt;p&gt;It reported &lt;strong&gt;21&lt;/strong&gt;. The replacement had touched &lt;strong&gt;365&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My verifier iterated over the top level of each &lt;code&gt;ld+json&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* check item.sameAs */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most &lt;code&gt;Organization&lt;/code&gt; nodes are not at the top level. They are nested inside &lt;code&gt;publisher&lt;/code&gt;, or &lt;code&gt;author&lt;/code&gt;, or &lt;code&gt;mainEntity&lt;/code&gt;. A flat scan sees a small fraction of them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I was one step away from telling my client the bulk edit had barely applied. &lt;strong&gt;When you verify a change to nested data, walk the tree.&lt;/strong&gt; A verifier that is structurally simpler than the data it checks will lie to you, and it will lie in the confident direction — a number, not an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I now run before any bulk edit to structured data
&lt;/h2&gt;

&lt;p&gt;Bulk-editing HTML with string replacement is exactly as dangerous as it sounds, and JSON-LD has a nasty property: a broken block does not break the page. The browser ignores it, the layout is fine, and the damage is invisible until someone runs a validator months later.&lt;/p&gt;

&lt;p&gt;So the script does this, per file, before writing anything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apply the replacement to an in-memory copy.&lt;/li&gt;
&lt;li&gt;Extract every &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; block and &lt;code&gt;JSON.parse&lt;/code&gt;
each one.&lt;/li&gt;
&lt;li&gt;If any block fails to parse, &lt;strong&gt;skip the file entirely&lt;/strong&gt; and log it.&lt;/li&gt;
&lt;li&gt;Only then write, keeping a backup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then a separate pass re-parses every block on the whole site — 1,753 of them — and reports the count of unparseable blocks. That number has to be zero.&lt;/p&gt;

&lt;p&gt;None of this is clever. It is just the acknowledgement that in the machine-readable layer, "it still looks fine" is not evidence of anything.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>javascript</category>
      <category>webperf</category>
    </item>
  </channel>
</rss>
