<?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: Mehdi Ben Haddou</title>
    <description>The latest articles on DEV Community by Mehdi Ben Haddou (@mehdibenhaddou).</description>
    <link>https://dev.to/mehdibenhaddou</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%2F4144430%2Fd32f7e1e-f937-4b04-8d69-a8301b753643.png</url>
      <title>DEV Community: Mehdi Ben Haddou</title>
      <link>https://dev.to/mehdibenhaddou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mehdibenhaddou"/>
    <language>en</language>
    <item>
      <title>Over a million chess reviews a month, and the engine runs on your phone, not my server</title>
      <dc:creator>Mehdi Ben Haddou</dc:creator>
      <pubDate>Sat, 26 Sep 2026 14:01:39 +0000</pubDate>
      <link>https://dev.to/mehdibenhaddou/over-a-million-chess-reviews-a-month-and-the-engine-runs-on-your-phone-not-my-server-3oah</link>
      <guid>https://dev.to/mehdibenhaddou/over-a-million-chess-reviews-a-month-and-the-engine-runs-on-your-phone-not-my-server-3oah</guid>
      <description>&lt;p&gt;Most chess sites limit free game review for a boring reason: running a chess engine over every move of every game costs real CPU, and someone has to pay for it. If a review takes 20 seconds of server compute and you have 40,000 of them a day, you have a bill.&lt;/p&gt;

&lt;p&gt;We started &lt;a href="https://www.chessigma.com" rel="noopener noreferrer"&gt;Chessigma&lt;/a&gt; in March 2025 because players kept complaining about that limit. The trick that makes it free and unlimited is not clever. The engine is compiled to WebAssembly and runs in your browser tab, on your own device. Our server fetches the game, works out a few stats at the end and stores the result. Today that's over a million reviews a month, and the expensive part of each one runs on somebody else's CPU.&lt;/p&gt;

&lt;p&gt;That part worked on day one. What I didn't expect is how much of the next year would go into one question: &lt;strong&gt;how do you tell a slow run from a dead one, on a device you can't see?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The split
&lt;/h2&gt;

&lt;p&gt;Roughly, this is who does what:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; your phone / laptop                         my servers
 ------------------------------------        ---------------------------
 download engine (.wasm, 7.28 MB)   &amp;lt;------  CDN
 spin up 1-2 Web Workers
 evaluate every position   (progress 0 to 80%)
 grade each move           (Sigma down to Clown)
 post the finished result           ------&amp;gt;  store it, compute a few
                                              stats, send them back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mobile gets one worker. Desktops with more than four cores get two. Nothing fancy, and it has held up. (Sigma and Clown are our move labels. A friendly trademark chat with Chess.com meant we couldn't borrow theirs, so a blunder here is a Clown. Nobody has complained.)&lt;/p&gt;

&lt;p&gt;The cost side is exactly what you'd hope. The failure side is where it gets interesting, because when the work runs on someone else's device, every failure happens somewhere you have no logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failures that never throw
&lt;/h2&gt;

&lt;p&gt;On a server, a job that dies usually tells you. In a browser tab on a cheap Android phone, a lot of things just... stop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;.wasm&lt;/code&gt; starts downloading, the connection drops halfway, and the worker never says a word.&lt;/li&gt;
&lt;li&gt;The OS freezes the WebView when the user switches apps. Your timers stop. When they come back, it looks like nothing happened for two minutes.&lt;/li&gt;
&lt;li&gt;The runtime traps inside the engine on some devices, and the worker goes quiet.&lt;/li&gt;
&lt;li&gt;The engine is fine, but it's slow, because it's a four-year-old phone on a train.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So we had a watchdog. It polled every two seconds, and if the analysis looked stuck, it gave up on the run. Reasonable. Here is the line that decided "stuck":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STALE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&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;CAP_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// keep waiting only if BOTH are true&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;msSinceProgress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;STALE_MS&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;msElapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CAP_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...otherwise give up&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it slowly. It keeps waiting only if progress moved recently &lt;strong&gt;and&lt;/strong&gt; the whole run is under 60 seconds. So any review that took longer than a minute got killed, even if it was moving along happily. A long game on a slow phone takes longer than a minute. That's not a stall, that's just a phone.&lt;/p&gt;

&lt;p&gt;It got worse. Giving up flipped the "analyzing" flag off without ever setting "ready", so the UI moved on to the report screen and rendered its initial state. Every accuracy was zero, every move count was zero. It looked like the engine had produced garbage. It hadn't produced anything.&lt;/p&gt;

&lt;p&gt;A user wrote in: "analysis stops, no move classifications". When I went to the data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The watchdog fired about 3,100 times in 14 days.&lt;/li&gt;
&lt;li&gt;At the moment it fired, the &lt;strong&gt;median run was 48.66% done&lt;/strong&gt;. These were healthy runs, cut in half.&lt;/li&gt;
&lt;li&gt;In the mobile app, where the watchdog was switched on, &lt;strong&gt;39.8% of users hit at least one failed analysis&lt;/strong&gt; in those two weeks. Almost all of them on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My first theory was wrong. I was sure we were saving corrupted engine output and serving it back from a cache. We weren't: the game in the report had zero analysis rows. The bug was one &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: make "stuck" a pure function
&lt;/h2&gt;

&lt;p&gt;The rewrite had one rule. Elapsed time only means something before progress has ever moved. Once the engine has reported anything, the only clock that matters is "time since the last progress".&lt;/p&gt;

&lt;p&gt;I pulled the decision out of the React hook into a pure function with no timers, no React and no imports, so it can be tested in plain Node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;analysisWatchdogVerdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WatchdogInput&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;WatchdogVerdict&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A frozen timer (backgrounded WebView) makes every clock lie,&lt;/span&gt;
  &lt;span class="c1"&gt;// so discount the late part of this tick before reading any budget.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;skewMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msSinceLastTick&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tickMs&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msSinceLastTick&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tickMs&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;startClock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msSinceStart&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;skewMs&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;progressClock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;msSinceProgress&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;skewMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Boot leg: progress never moved, so elapsed time is all we have.&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;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastProgress&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&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;startClock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;BOOT_BUDGET_MS&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never_started&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&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="c1"&gt;// Mid-run: only the progress clock counts. No absolute cap.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;budgetMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastProgress&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;METRICS_STALL_BUDGET_MS&lt;/span&gt;  &lt;span class="c1"&gt;// network phase&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ENGINE_STALL_BUDGET_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// CPU phase, one position at a time&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;progressClock&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;budgetMs&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stalled&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;running&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Trimmed. The real one also handles "engine never said it was ready" and returns how far over budget it was, for telemetry.)&lt;/p&gt;

&lt;p&gt;The part I'm happiest with is that every budget now has its arithmetic written next to it instead of being a round number someone liked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Boot, 90 s.&lt;/strong&gt; 7.28 MB is 58 Mbit. At a sustained 1 Mbps that's 58 seconds of download, plus a few seconds to compile the wasm on a mid-range Android, plus the handshake and the first position. About 65 seconds at the slow end. 90 gives headroom, and below roughly 0.7 Mbps the download genuinely can't finish, so saying so is the right answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engine stall, 20 s.&lt;/strong&gt; Progress fires once per position, and the engine's own per-position timeout tops out around 14 seconds at the depth we use. A CI check pins that relationship, so if someone raises the depth later, the build fails instead of the watchdog quietly killing slow runs again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network stall, 45 s.&lt;/strong&gt; After the engine phase, the gaps are HTTP calls with retries. Three attempts that each hit a 14 s function timeout, plus backoff, is 43 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And because it's a pure function, I could do the one test that matters: put the old 60-second cap back in and watch CI go red.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other half: the bytes have to arrive first
&lt;/h2&gt;

&lt;p&gt;All of this assumes the engine file actually reaches the device. Around the same time we learned how optimistic that was, from a completely different direction.&lt;/p&gt;

&lt;p&gt;In April we moved every static asset (JS, CSS, the engine) to a CDN on a separate domain. Standard &lt;code&gt;assetPrefix&lt;/code&gt; stuff. Four months later, someone on our Discord posted a screenshot of the site with no styles at all, on an iPad. Then another one.&lt;/p&gt;

&lt;p&gt;We checked everything we could think of. DNS resolved fine on six public resolvers. The TLS chain was fine. Every file returned 200. Nothing in our error tracking. And that last one is the actual lesson: &lt;strong&gt;our analytics script was part of the bundle served from the CDN.&lt;/strong&gt; A user who couldn't reach the CDN never sent us a single event. For four months, the only monitoring we had for this failure was people bothering to complain.&lt;/p&gt;

&lt;p&gt;We added a tiny beacon served from the main domain, and the first day of data said roughly &lt;strong&gt;150 page loads a day&lt;/strong&gt; were arriving with no CSS. iOS Safari was heavily over-represented. The causes were boring and plural: content blockers and filtering DNS treating a different domain as third party, corporate proxies with allowlists, and TLS interception, which fails hard on a &lt;code&gt;.dev&lt;/code&gt; domain because the whole TLD is HSTS-preloaded and there's no "continue anyway" button.&lt;/p&gt;

&lt;p&gt;Two findings from that week I'd put on a poster:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 CSS files break the page, 37 JS files don't.&lt;/strong&gt; Block all the JS and the site looks perfect, just dead. Block two stylesheets and it looks like 1998. So the stylesheets are where a fallback pays for itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You can't catch the first stylesheets with an &lt;code&gt;error&lt;/code&gt; listener.&lt;/strong&gt; Our first fix did exactly that and rescued none of the CSS. React hoists stylesheets to the top of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, and a render-blocking stylesheet blocks every script after it, so by the time the parser reaches your listener, the CSS has already failed. We measured it on Chromium, Firefox and WebKit. Same order every time.&lt;/p&gt;

&lt;p&gt;Checking &lt;code&gt;document.styleSheets&lt;/code&gt; doesn't help either. A failed sheet is still listed there, and you can't read a cross-origin sheet's rules, so an empty 200 from a blocker looks healthy. What finally worked is almost embarrassing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* last line of our global stylesheet */&lt;/span&gt;
&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--cs-stylesheet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ok&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// inline, in the HTML, so it doesn't depend on the CDN&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getComputedStyle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPropertyValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--cs-stylesheet&lt;/span&gt;&lt;span class="dl"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// our CSS never reached the CSSOM, whatever the reason:&lt;/span&gt;
  &lt;span class="c1"&gt;// refused, DNS, TLS, 403, 404, blocked, empty body.&lt;/span&gt;
  &lt;span class="c1"&gt;// Re-request every CDN stylesheet from the main origin.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One custom property covers every failure shape at once, because it doesn't ask &lt;em&gt;why&lt;/em&gt; the CSS is missing. It asks whether the CSS is there.&lt;/p&gt;

&lt;p&gt;Two smaller things that bit us along the way, in case they save you an afternoon:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Our CDN (Cloudflare) cached 404s at the edge for 4 hours. So "just hard refresh" did nothing for anyone who hit a missing file once. A browser refresh can't clear an edge cache.&lt;/li&gt;
&lt;li&gt;Cloudflare decides what to cache by &lt;strong&gt;file extension&lt;/strong&gt;, not MIME type. &lt;code&gt;.js&lt;/code&gt; is on the default list. &lt;code&gt;.wasm&lt;/code&gt; isn't. Our 20 KB engine loader was served from the edge, and the 7.28 MB engine right next to it went back to origin every time, with identical cache headers. &lt;code&gt;curl -I&lt;/code&gt; won't show you this properly either, because it sends HEAD. Test with a GET.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell myself a year ago
&lt;/h2&gt;

&lt;p&gt;Running the engine on the user's device was the right call, and I'd do it again. It's why the review can be free and unlimited at all. But it moves your reliability problem somewhere you can't see, and a few habits make the difference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your monitoring can't live in the thing it monitors.&lt;/strong&gt; If the analytics ships inside the bundle, a failed bundle is silent. Keep one tiny beacon on the main origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Slow" and "dead" need different clocks.&lt;/strong&gt; Elapsed time says nothing once work is progressing. Time since last progress does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the arithmetic next to the timeout.&lt;/strong&gt; "60 s" came from nowhere and killed half-finished runs. "7.28 MB at 1 Mbps" can be argued with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull decisions out of hooks.&lt;/strong&gt; The watchdog bug lived unnoticed inside a &lt;code&gt;useEffect&lt;/code&gt;. As a pure function, the test that would have caught it is a few lines of plain Node.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see the result, paste any Chess.com or Lichess username on &lt;a href="https://www.chessigma.com" rel="noopener noreferrer"&gt;chessigma.com&lt;/a&gt; and watch your laptop fans do my job.&lt;/p&gt;

&lt;p&gt;I'm curious how others handle this. If you ship heavy WebAssembly to phones, how do you tell a slow device from a dead worker? A heartbeat from inside the worker, per-step timeouts, something smarter? I'm still not sure we've found the best version.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>javascript</category>
      <category>webassembly</category>
    </item>
  </channel>
</rss>
