Most "find the best chess move" websites work the same way: you send a position to their server, an engine analyses it there, and they pay for every second of CPU. That's why so many of them cap free analysis per day or fill the page with ads.
When I built Chess Move Finder, I went the other way. Stockfish 17.1 runs entirely in the user's browser. The server only serves static files. Analysis costs me nothing per user, so the tool can stay free with no account, no ads and no daily limit.
Here's how it works and the tradeoffs I ran into.
The stack
- Next.js (App Router) for the site, tools, blog and translated pages
- Stockfish 17.1, compiled to WebAssembly, using the lite, single-threaded build
- The engine runs in a Web Worker, so the board and UI never freeze while it thinks
Why the "lite single-threaded" build
Stockfish's WebAssembly builds come in a few flavours, and the choice matters more than I expected.
Multi-threaded builds need SharedArrayBuffer. That means the page must be cross-origin isolated: you have to send Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. That's fine for a single-page app, but it breaks a lot of normal things on a content site: third-party embeds, some analytics, images from other origins. My site has a blog and other tools alongside the engine, so I didn't want those headers everywhere.
The single-threaded build needs none of that. It works on any page, in any modern browser, on any phone.
"Lite" means a smaller neural network (NNUE) file. The full network is large; the lite one is a fraction of the size. It plays slightly weaker, but it's still far stronger than any human, and the first analysis starts much faster, especially on mobile data. For a "what's the best move here?" tool, a fast first answer matters more than the last few Elo points.
Talking to the engine: UCI over postMessage
Stockfish speaks UCI (Universal Chess Interface), a plain-text protocol. Inside a Web Worker, you send it strings and listen for strings back:
const engine = new Worker('/engine/stockfish.js'); engine.onmessage = (e) => { const line = e.data; if (line.startsWith('info') && line.includes(' pv ')) { // depth, score (cp or mate), and the principal variation } if (line.startsWith('bestmove')) { const move = line.split(' ')[1]; // e.g. "e2e4" } }; engine.postMessage('uci'); engine.postMessage('setoption name MultiPV value 3'); // top 3 lines engine.postMessage('position fen ' + fen); engine.postMessage('go movetime 1500'); A few things worth getting right:
- Wait for
uciok/readyokbefore sending a position, especially on slow devices where the WASM takes a moment to initialise. - Always send
stopbefore a new search. Users click fast. Without it, results from an old position can arrive after you've shown a new one. MultiPV 3gives the top three lines in one search. That powers the "compare the top lines" view, so users can see why the best move beats the alternatives.
Quick vs Deep: time-boxed search
Rather than searching to a fixed depth, the tool uses a fixed time per search:
- Quick: about 1.5 seconds. Enough for most positions on most devices.
- Deep: about 5 seconds. For sharp tactical positions where a longer search changes the answer.
Fixed depth sounds cleaner, but depth 20 takes wildly different times on a desktop and a budget phone. A time budget keeps the experience predictable, and the engine simply reaches a deeper depth on faster hardware.
Making engine output readable
Raw UCI output isn't something most chess players want to read. The UI turns it into:
- An arrow on the board for the best move
- An evaluation: positive favours White, negative favours Black, and M means a forced mate is on the board
- The top three lines, written as normal chess moves
The UCI score is always from the side to move's perspective, so you have to flip the sign when it's Black to move to show a consistent White-relative evaluation. It's a classic first bug.
FEN in, FEN out
Most users arrive with a position from somewhere else: a game on Chess.com or Lichess, a puzzle, a book. So the tool accepts a FEN string and also lets you set up the board by hand. Every position can also be shared with a link, so you can send it to a friend and ask "what would you play here?"
What else is in there
Once the engine was running client-side, adding more tools on top of it was cheap:
- Game review from a PGN, plus practice from your own mistakes
- 720 tactics puzzles from the Lichess open puzzle database, filterable by difficulty and theme
- An opening trainer for 20 openings
- Play against the computer at several levels
All of it reuses the same in-browser engine.
Tradeoffs worth knowing
- Weaker devices give shallower analysis in the same time. For a move-finder that's acceptable; for deep correspondence analysis it wouldn't be.
- First load downloads the engine and network. The lite build keeps that reasonable, and the browser caches it afterwards.
- No server means no central analysis cache. Every user computes their own positions. That's fine at this scale, and it's also why there's nothing to rate-limit.
Try it
If you play chess (or just want to see Stockfish running in a tab), give it a go: chessmovefinder.com. Paste a FEN, pick who moves, and see what Stockfish thinks.
I'd love feedback, especially from anyone who has shipped WebAssembly engines in production. What would you have done differently?