<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Browser notes]]></title><description><![CDATA[Browser notes]]></description><link>https://browser-notes.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Browser notes</title><link>https://browser-notes.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Fri, 25 Sep 2026 12:52:22 GMT</lastBuildDate><atom:link href="https://browser-notes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I run Stockfish in your tab because I don't want your position on my server]]></title><description><![CDATA[Most "analyze your chess game" pages are a form with a POST behind it. You paste a PGN, the bytes go to someone's backend, a pool of engine workers chews on it, and JSON comes back. That is the obviou]]></description><link>https://browser-notes.hashnode.dev/i-run-stockfish-in-your-tab-because-i-don-t-want-your-position-on-my-server</link><guid isPermaLink="true">https://browser-notes.hashnode.dev/i-run-stockfish-in-your-tab-because-i-don-t-want-your-position-on-my-server</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[webdev]]></category><category><![CDATA[chess]]></category><dc:creator><![CDATA[Dmitriy Sevryukov]]></dc:creator><pubDate>Sat, 12 Sep 2026 15:35:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aa569de4733d1e7e5adf8bb/d7bf7da5-41dd-4aaf-a1fd-23c19393ac24.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most "analyze your chess game" pages are a form with a POST behind it. You paste a PGN, the bytes go to someone's backend, a pool of engine workers chews on it, and JSON comes back. That is the obvious architecture. It is also the one I decided not to build for a public review page.</p>
<p>The page I'm going to talk about is a plain website page — no account, no install, no extension. You paste a PGN of a finished game or a FEN of a single position, and the analysis happens in your browser tab, in a Web Worker, using a WebAssembly build of Stockfish 18. The interesting part isn't "WASM is fast now." It's what changes in the design when the engine lives on the reader's side of the wire.</p>
<h2>The thing a server-side analyzer quietly asks for</h2>
<p>A chess position is not especially sensitive data. Nobody's identity leaks from <code>r1bqkbnr/pppp1ppp/2n5/4p3/2B1P3/5Q2/PPPP1PPP/RNB1K1NR b KQkq - 3 3</code>. But a <em>game</em> is a bit more than a position. A PGN usually carries the headers: the site, the event, the date, and both player handles. If I accept PGN uploads on a server, I am accepting a log of "this handle played this game on this date and then someone pasted it into my analyzer." I would then have to decide how long to keep it, who on my side can read it, what my privacy page promises, and what happens when that promise and my actual log retention disagree.</p>
<p>The cheapest way to be honest about all of that is to not receive the data. If the position never leaves the tab, there is no retention policy to get wrong, no analysis endpoint to rate-limit, no engine pool to scale, and no bill that grows with how long people think.</p>
<p>That last one matters more than the privacy argument does, commercially. Server-side analysis has a cost curve shaped exactly like engagement: the more seriously someone studies their game, the more CPU-seconds I buy for them. In-tab analysis has a cost curve shaped like static asset delivery. The user's own laptop pays for depth, which is also the only machine that cares how deep the search goes.</p>
<h2>What "it stays in your tab" actually means</h2>
<p>I want to be precise here, because this claim gets oversold.</p>
<p>Opening the page downloads things: the page JS, the engine glue script, and the <code>.wasm</code> binary. Those are network requests, and they show up in DevTools. Anyone claiming "zero network" about a web page is describing something that isn't a web page.</p>
<p>The claim I'll make is narrower and checkable: <strong>the FEN or PGN you paste is not sent to an analysis API.</strong> You can open the Network panel, paste a game, let it run, and watch. Requests during the search: none carrying your position. The engine has already arrived; from that point the work is local.</p>
<p>I like this property because it's falsifiable by the reader in about fifteen seconds, without trusting me, my privacy page, or a badge. That's a rare thing to be able to offer. Most privacy claims on the web are assertions about servers you cannot inspect. This one is an assertion about your own network tab.</p>
<h2>The plumbing</h2>
<p>Stockfish's WASM distribution comes in several flavors, and choosing among them is most of the engineering decision. The multi-threaded builds are substantially stronger per unit of wall-clock time, but they need <code>SharedArrayBuffer</code>, which means cross-origin isolation: <code>Cross-Origin-Opener-Policy: same-origin</code> plus <code>Cross-Origin-Embedder-Policy: require-corp</code> on the document. Turning that on is not a local change. It changes how every third-party subresource on that page has to behave — anything without proper CORP headers stops loading. For a marketing-adjacent public page that also carries normal web furniture, that's an invasive header to adopt for one feature.</p>
<p>So the page ships the lite single-threaded build, served from <code>/stockfish/stockfish-18-lite-single.js</code>. No isolation requirements, no <code>SharedArrayBuffer</code>, works in a normal tab under normal headers. It is slower. I'll come back to that.</p>
<p>The engine runs in a Worker, not on the main thread, for the obvious reason: a search is an unbounded busy loop, and a busy loop on the main thread means the page stops scrolling, the spinner stops spinning, and the tab gets the "not responding" treatment. Off the main thread, the UI stays alive and the search is just a chatty message stream.</p>
<p>The interface is UCI over <code>postMessage</code>. If you've only ever used chess engines through a GUI, the protocol is refreshingly boring — line-oriented text, same as it would be over stdin to a binary:</p>
<pre><code class="language-js">const engine = new Worker('/stockfish/stockfish-18-lite-single.js');

engine.onmessage = (e) =&gt; {
  const line = typeof e.data === 'string' ? e.data : e.data?.data;
  if (line === 'uciok') engine.postMessage('isready');
  if (line === 'readyok') startSearch();
  if (line.startsWith('info depth')) renderProgress(parseInfo(line));
  if (line.startsWith('bestmove')) settle(line.split(' ')[1]);
};

function startSearch() {
  engine.postMessage('position fen ' + fen);
  engine.postMessage('go depth 18');
}

engine.postMessage('uci');
</code></pre>
<p>That's the shape of it, not the production code. The parts that turned out to need real care are the ones this snippet glosses over. <code>position fen …</code> followed immediately by <code>go</code> is fine for one position; for a PGN you are doing it dozens of times in sequence, and you need a queue with an explicit notion of "the user changed their mind." Abandoning a search means sending <code>stop</code> and then actually waiting for the <code>bestmove</code> that follows it, because the worker will emit it regardless and a naive implementation will happily attribute it to the <em>next</em> position. Stale results wearing a fresh label is the single easiest bug to ship here, and it's invisible in testing because the numbers still look like plausible evaluations.</p>
<p>The other thing worth saying: <code>info</code> lines arrive continuously as the search deepens, and the evaluation moves. At depth 12 a move looks fine; at depth 20 it's the losing move. If you render every <code>info</code> line straight into the UI you get a number that jitters and occasionally flips sign, which reads as a broken product even though it's the engine working correctly. Deciding what's worth showing mid-search — and how much to smooth — is a UI problem that the protocol doesn't help you with at all.</p>
<h2>The limits, stated plainly</h2>
<p>The lite single-threaded build is meaningfully weaker per second than desktop Stockfish with a real thread count and a real hash table. On a long game, the first pass is slow — you are watching moves get analyzed one at a time on one thread in a browser. Evaluations drift as depth increases, which is honest behavior but looks unstable if you are staring at the number.</p>
<p>If your goal is correspondence-grade analysis of a 90-move game, a native engine on your own machine will beat this, and I'd tell you to go do that. What the page is good at is the common case: you just finished a game, you know roughly where it went wrong, and you want to check that intuition against an engine without creating an account or installing anything.</p>
<p>Scope is deliberately narrow for the same reason. The page analyzes a finished game or a static position. It does not attach to a game in progress, it does not play moves for you, and it does not write anything into an account, because there is no account.</p>
<h2>Fair play</h2>
<p>Stating the obvious so it isn't ambiguous: using an engine during a rated game against another human is cheating. Lichess and Chess.com both ban for it, and they're right to. This page is for a game that is already over, or for a position you're studying. That distinction is the whole reason the scope above is drawn where it is — "review a finished PGN" and "help me in my current game" are different products, and conflating them is how you end up shipping something you shouldn't.</p>
<p>I mention this partly because I also build a Chromium extension that shows a hint next to a live board, and I'd rather be explicit about the line than let someone infer that a browser-based reviewer is a stealth tool. Separately, the extension is the reason a no-install reviewer exists at all: plenty of people want to look at one game once, and asking them to install software for that is a bad trade. Different delivery, different constraints — the extension has its own architecture problems around background contexts and engine lifetime, which is a different article.</p>
<p>Two small clarifications so I'm not claiming credit I haven't earned: the engine is Stockfish, which is not mine, and this page is not an open-source project. The public repository holds release artifacts and checksums, not the source.</p>
<h2>The open question</h2>
<p>Here is what I haven't solved well. Once you have per-move evaluations for a whole game, you have to decide which swings deserve the reader's attention. A fixed centipawn threshold is wrong at both ends — 150cp in a dead-equal middlegame is the moment the game turned, while 150cp at +9 is noise. Win-probability mapping is better but flattens exactly the sharp positions where the mistake was most instructive. And single-PV output hides the cases where three moves were nearly equal and the "best" label is arbitrary.</p>
<p>If you've built a game-review UI: what do you actually use to rank moments — raw centipawn delta, a win-probability transform, the gap between PV1 and PV2, or something that accounts for how hard the right move was to find? I'm curious whether anyone has landed on something that survives contact with both blunders and quiet positional drift.</p>
<p>The page that does this is <a href="https://chessnavio.com/analyze">the browser reviewer</a>. How the local boundary is drawn, including the extension, is on <a href="https://chessnavio.com/how-it-works">how it works</a>.</p>
]]></content:encoded></item></channel></rss>