DEV Community

Pukar Khanal
Pukar Khanal

Posted on

Why free chess analysis is always capped at one game a day

Most free chess game review gives you one game per day. Chess.com works that way, and so does almost every smaller site offering the feature. I assumed for a long time that this was just a paywall placed where it hurts.

It is partly that. But there is a real cost sitting behind the cap, and once I worked out what the cost was, I built my own analysis site differently.

The cost of one game review

Reviewing a 40 move game means evaluating about 80 positions. Give the engine two seconds on each one and you have spent close to three minutes of CPU. None of it is cacheable, because your game is not anyone else's game.

Run that on your own hardware and you pay for every minute. A thousand people reviewing one game a day is roughly 50 CPU hours daily, for a feature you are giving away. The quota is not greed. It is the number that stops the free tier from eating the company.

Which raises a more interesting question than "how do I price this". What happens if you delete the cost instead of rationing it?

Move the engine to the client

Stockfish compiles to WebAssembly. Put it in a Web Worker and the visitor's own processor spends those three minutes. Your server ships static files and never sees a chess position.

The whole free tier problem disappears, because there is no per-user cost left to control. Nothing to meter, so nothing to cap.

Getting started is unremarkable:

const engine = new Worker("stockfish.js");
engine.postMessage("uci");
engine.postMessage("isready");
Enter fullscreen mode Exit fullscreen mode

After that you speak UCI over postMessage. Set a position, ask the engine to think, and read results off the message stream:

engine.postMessage(`position fen ${fen}`);
engine.postMessage(`go depth 15 movetime 2000`);
Enter fullscreen mode Exit fullscreen mode

That is the pitch. Now the parts nobody mentions.

The protocol is strings, and it is asynchronous

UCI was designed for a pipe between two processes. You get that pipe, faithfully, with all of its ergonomics intact. The engine answers with lines like this:

info depth 15 seldepth 22 score cp -34 pv e2e4 e7e5 g1f3
bestmove e2e4 ponder e7e5
Enter fullscreen mode Exit fullscreen mode

So you write regexes against engine output:

const cpMatch = output.match(/score cp (-?\d+)/);
const mateMatch = output.match(/score mate (-?\d+)/);
Enter fullscreen mode Exit fullscreen mode

Two things about this bite you. The first is that info lines stream continuously as the search deepens, and only the last one before bestmove reflects the depth you asked for. Read the wrong line and your evaluation comes from a depth 4 search. You have to hold the most recent value and commit it when bestmove arrives.

The second is that a mate score is not a centipawn score. score mate 3 means mate in three, and if you feed it through the same /100 you use for centipawns you get an evaluation of 0.03 for a forced win. Handle it as a separate branch or your graph will show a winning position as dead level.

One engine, eighty positions

The obvious way to review a game is a loop over the moves. The obvious way is also wrong on the first attempt, because a Worker is a single engine and postMessage does not queue by request. Fire off 80 positions in a Promise.all and you get 80 sets of interleaved output from one engine with no way to tell which line belongs to which position.

So the loop has to be serial. Await each position, resolve on its bestmove, then send the next. In my case that means attaching a listener per position and removing it on resolve:

const listener = (event) => {
  const output = event.data;
  if (output.startsWith("bestmove")) {
    eng.removeEventListener("message", listener);
    resolve({ evaluation: bestEval, bestMove, engineLine });
  }
};
Enter fullscreen mode Exit fullscreen mode

Forget the removeEventListener and every position after the first has listeners from all previous positions still attached, each one resolving a promise that has already resolved. It does not throw. It just quietly produces wrong results, which is worse.

Create the Worker once and terminate it on unmount. In React that is a useEffect with an empty dependency array and a cleanup that calls terminate(). Creating one per analysis leaks a Worker every time.

The thing I got wrong: threads

Stockfish is much stronger with multiple threads. Multi-threaded WASM needs SharedArrayBuffer, and since Spectre, browsers only hand you SharedArrayBuffer on a cross origin isolated page. That means two response headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Enter fullscreen mode Exit fullscreen mode

My site does not send them. I checked while writing this, which is a slightly embarrassing way to find out. So every analysis on chessdream.app today runs single threaded, and my depth 15 default is doing more work than it needs to for the strength it delivers.

The reason it is not a one line fix is require-corp. Turning it on breaks every cross origin resource that does not opt in with CORP headers, which on my site means the ad script and the analytics tags. That is a real tradeoff and not obviously worth it, given single threaded Stockfish at depth 15 already plays far above any human who is using a free analysis site to review their blitz games.

Worth knowing before you promise yourself threads.

Turning evaluations into words

Users do not want centipawns. They want to be told that move 23 was the blunder. So you take the evaluation delta between consecutive positions and bucket it:

if (moveUci === bestMove || bestMoveSan === moveSan) {
  if (evalDiff > 2) return "brilliant";
  if (evalDiff > 1) return "great";
  return "best";
}
if (evalDiff >= 0) return "excellent";
// ...
if (evalDiff < -1.5) return "blunder";
Enter fullscreen mode Exit fullscreen mode

I want to be honest that these thresholds are judgment, not mathematics. There is no objective centipawn value at which a move becomes a blunder. Every site that labels moves picked numbers that felt right, mine included, and the labels are a user interface decision wearing the costume of an engine output.

One asymmetry to remember: the delta is from the mover's perspective. Black losing half a pawn is evalDiff positive in raw terms and negative in meaning, so you flip the sign for black before bucketing. Skip that and half your board gets praised for its mistakes.

What you actually pay

Nothing here is free. Moving the engine to the client trades your costs for the user's.

The download is real. On my deployment stockfish.wasm is 546KB, plus 146KB of loader. There is also a 2.6MB asm.js fallback for browsers without WASM, which in 2026 is close to nobody, and I should probably drop it.

Results vary by device, which is the part I find genuinely annoying. The same position on the same site gives a different evaluation on a four year old phone than on a desktop, because the phone reaches a lower depth in the same two seconds. Server side analysis is consistent for everyone. Client side analysis is as good as whatever the visitor is holding.

And you get no telemetry on analysis quality, because the analysis never touches your servers. That is the same property that lets you promise nothing is uploaded. You cannot have both.

Was it worth it

For this use case, yes, and not mainly for the cost saving.

The privacy claim turns out to be the part people react to. "Your games never leave your browser" is not a policy or a promise to be trusted. It is a description of where the code runs, and anyone can open devtools and confirm there is no request. I did not expect that to matter as much as it does.

The unlimited analysis follows from the architecture rather than from generosity. I am not choosing to be nice. There is simply no meter to read, so there is nothing to cap, and a business decision I would otherwise have had to make repeatedly is just gone.

If you are building something where the expensive per-user work could run on the user's machine, it is worth pricing out the client side version before you design a quota. The quota is often just the shadow of a server bill.

Site is at chessdream.app if you want to see it running. Happy to answer questions about the Worker plumbing, it is the part I spent the most time getting wrong.

Top comments (0)