A few weeks ago I built StrandsSolver, a free solver for the NYT Strands daily word puzzle. The whole thing runs in the browser — no backend, no database, no API calls. You paste a letter grid, and it finds every valid word instantly.
There were two constraints that made this interesting:
- It has to be fast. A 6×6 grid with 8-direction movement has a huge search space. If the solver takes more than a second, people close the tab.
- It has to be private. The grid you paste should never leave your device. That means the entire word list — 171,755 words — lives in a single JavaScript file, and all the work happens client-side.
Here's how it works under the hood.
The core algorithm: Trie + DFS with prefix pruning
The solver is conceptually simple: for every cell in the grid, do a depth-first search in all 8 directions, and at each step check whether the current path is still a valid prefix of some word. If it's not, prune the branch immediately.
The key data structure that makes this fast is a Trie (prefix tree). Instead of scanning the whole dictionary for each prefix, we walk the Trie one letter at a time. If a node has no child for the next letter, that branch is dead — we stop recursing.
function TrieNode() {
this.children = Object.create(null);
this.isEnd = false;
}
function buildTrie(words) {
const root = new TrieNode();
for (let i = 0; i < words.length; i++) {
const w = words[i];
let node = root;
for (let j = 0; j < w.length; j++) {
const c = w[j];
if (!node.children[c]) node.children[c] = new TrieNode();
node = node.children[c];
}
node.isEnd = true;
}
return root;
}
With Object.create(null) as the child map, lookup is a plain property access — no prototype chain, no hash collisions, just a direct jump. For 171k words of average length ~8, building the Trie takes about 100ms, and we only do it once per solve.
The DFS
The grid is flattened into a single array, and the DFS walks 8 direction deltas:
const DIRS = [
[-1,-1], [-1,0], [-1,1],
[0,-1], [0,1],
[1,-1], [1,0], [1,1],
];
function dfs(pos, node, depth) {
const ch = flat[pos];
const child = node.children[ch];
if (!child) return; // prefix not in dictionary — prune
path[depth] = pos;
onPath[pos] = 1;
if (child.isEnd && depth >= 3) {
// depth is 0-indexed → depth+1 letters; Strands min is 4
const word = snapshotWord(depth);
if (!found.has(word)) found.set(word, snapshotPath(depth));
}
const r = (pos / cols) | 0;
const c = pos % cols;
for (let d = 0; d < 8; d++) {
const nr = r + DIRS[d][0], nc = c + DIRS[d][1];
if (nr < 0 || nc < 0 || nr >= rows || nc >= cols) continue;
const np = nr * cols + nc;
if (onPath[np]) continue; // no cell repeats within a word
dfs(np, child, depth + 1);
}
onPath[pos] = 0; // backtrack
}
for (let start = 0; start < total; start++) {
dfs(start, trie, 0);
}
A few details worth calling out:
-
onPathas aUint8Array: marking visited cells and clearing them on backtrack is O(1), and typed arrays are fast to allocate and read. -
No cell may repeat within a word — that's a Strands rule, enforced by the
onPathcheck. -
Prefix pruning is the whole game. Without the Trie, a branch that starts with
QZwould keep recursing pointlessly. With it,QZdies at depth 2, and the entire subtree below is never visited.
Parsing forgiving input
The other 50% of "it just works" is input parsing. Players paste grids in wildly different formats — newlines, commas, spaces, no separators at all, lowercase, with stray characters.
// Split rows by newline; if it's a one-liner, try comma/semicolon split
let rowLines = cleaned.split('\n').map(s => s.trim()).filter(Boolean);
if (rowLines.length === 1 && /[,;]/.test(rowLines[0])) {
rowLines = rowLines[0].split(/[,;]/).map(s => s.trim()).filter(Boolean);
}
Each cell is uppercased and stripped of non-letters. Rows are validated to be rectangular, with a hard cap of 100 cells so a pathological paste can't freeze the tab:
if (grid.length * cols > 100) {
throw new Error('Grid is too large (max 100 cells).');
}
The spangram heuristic
Strands has a special word type called the spangram — a theme word that spans from one edge of the board to the opposite edge. Since we don't know the official theme, we can't know for sure which word is the spangram, but we can flag strong candidates: any found word whose path touches two opposite edges.
if ((touchesTop && touchesBottom) || (touchesLeft && touchesRight)) {
spangramCandidates.push(w);
}
It's a heuristic, not a guarantee — but in practice it surfaces the right word most days, which is all the user needs.
Performance
On a 6×6 grid with the full 172k-word dictionary, the solver finds every valid word in well under 100ms on a mid-range laptop, and a couple hundred ms on a phone. The Trie keeps the search space tiny: most branches die within 2–3 letters.
That speed is the whole product. A solver that takes 3 seconds to respond feels broken; one that answers instantly feels like magic.
Why client-side?
Beyond privacy, going 100% static means zero infrastructure cost and zero maintenance:
- No server to scale, no API key to rotate, no database to back up
- Deploys anywhere — Cloudflare Pages, Netlify, GitHub Pages, a USB stick
- The word list is public domain (ENABLE), so there's no licensing concern
The tradeoff is a heavier first page load (the 172k-word JS file is ~1.5MB raw, ~250KB gzipped), which we mitigate with a small loading state and by keeping the solver logic in a separate file so it parses only when needed.
Key takeaways
If you're building anything that searches a big word space in the browser:
- A Trie + DFS with prefix pruning is hard to beat for "find all valid words on a grid" problems. It's simple, fast, and memory-light.
-
Typed arrays and flat arrays beat nested structures for hot paths. Flattening the grid and using
Uint8Arrayfor visited-state makes a measurable difference. - Prune early, prune often. Every wasted recursion level multiplies through the tree. The Trie is the pruning.
- Forgiving input parsing is a feature. Users paste grids from screenshots, tweets, and other apps — the parser doing the cleanup is what makes the tool feel instant and effortless.
The full source is on GitHub at strandssolver, and you can try the live solver at strandssolver.org.
If you've built something similar — Boggle solvers, Scrabble helpers, crossword tools — I'd love to hear how you handled the dictionary. Drop a comment below.
Top comments (0)