Type seven letters into a word unscrambler and the problem looks small. There are only seven tiles, after all.
The search space behind the input is not small.
My Word Unscrambler and Scrabble Word Finder searches a deduplicated index of 272,405 words. A query can include repeated letters, up to two wildcard tiles, a glob-like board pattern, dictionary selection, score limits, required or excluded letters, vowel and consonant limits, hook filters, and several strategic sort modes.
It does all of that without sending the rack to an API. The dictionary search runs locally in the browser through a Rust engine compiled to WebAssembly (WASM).
The browser-based search applies dictionary, pattern, hook, scoring, and sorting rules locally through a Rust/WASM engine.
This is how I divided the system between JavaScript and Rust, how the search avoids scanning the entire dictionary on an ordinary query, and why WASM made a meaningful difference without making JavaScript obsolete.
The architecture in one view
The application is a static site. There is no search server hiding behind the form.
HTML and CSS
│
▼
JavaScript UI
├── validates the rack and pattern
├── fetches and decompresses dictionary shards
├── manages DOM rendering and keyboard controls
└── stores History and Pick List data in localStorage
│
▼
JavaScript → WASM bridge
│
▼
Rust search engine
├── builds dictionary indexes
├── matches racks and wildcard patterns
├── applies filters
├── calculates scores and hooks
└── returns the final ordered word array
That boundary is deliberate. JavaScript owns browser work because it is excellent at browser work. Rust owns the stateful, data-heavy search pipeline.
The bridge exposes a small contract:
MonkeyTacticsWasm.initEngine(records);
MonkeyTacticsWasm.unscramble(rack, pattern, options);
MonkeyTacticsWasm.scoreWord(word);
MonkeyTacticsWasm.findHooks(word, dictionaryBit);
MonkeyTacticsWasm.analyzeWord(word);
MonkeyTacticsWasm.boardFitAnalysis(rack, pattern, options);
The generated wasm-bindgen loader instantiates the module, while serde-wasm-bindgen converts JavaScript option objects and Rust results across the boundary.
Do less work before doing faster work
Compiling a brute-force search to WASM would still leave me with a brute-force search. The largest gains come from the data model and from refusing to load or inspect irrelevant data.
1. Split the dictionary into lazy shards
The combined ENABLE and SOWPODS data is divided into 26 gzip-compressed files, one per first letter.
For an ordinary rack such as RETAINS, a result must begin with one of the letters in that rack. The browser therefore loads only the a, e, i, n, r, s, and t shards. Loaded shards and in-flight requests are cached for the page session.
A wildcard can stand for any initial letter, so a wildcard search may need all 26 shards. Hook analysis can also require the complete index: a front hook may add any letter to the beginning of a word. Those expensive paths are supported, but they are deferred until the user asks for them.
This matters as much as language choice. The fastest byte is still the byte the browser never downloads.
2. Index canonical signatures
Every dictionary word receives a canonical signature made by sorting its ASCII letters:
listen → eilnst
silent → eilnst
enlist → eilnst
Rust stores words under that signature and keeps a second index of signatures by word length. A search for words of a permitted length traverses only the relevant signature groups.
The rack itself becomes a fixed 26-element frequency array plus a wildcard count. Testing a signature is then a compact count comparison. If the signature needs more missing letters than the available wildcards can cover, the entire anagram group is rejected immediately.
This also handles repeated letters correctly. A rack with one e cannot build a candidate requiring two.
3. Precompute metadata once
While indexing a word, the Rust engine stores its:
- dictionary membership bit mask;
- Scrabble tile score;
- vowel count; and
- 26-element letter-frequency array.
Searches reuse that metadata instead of recounting and rescoring the same words on every filter pass. ENABLE membership uses bit 1, SOWPODS uses bit 2, and a word in both uses bit 3, so dictionary selection becomes a bitwise membership check.
The engine also caches hook results by word and selected dictionary. If another shard is indexed, that cache is cleared because the new data may reveal a hook that was not known before.
What happens during one search
Once the needed dictionary shards are indexed, the Rust pipeline is roughly:
- Convert the rack to letter counts and a wildcard count.
- Determine the feasible word-length range.
- Reject impossible pattern and length combinations early.
- Traverse signatures only in the permitted lengths.
- Reject signatures the rack cannot build.
- Check words in surviving groups against dictionary, pattern, prefix, suffix, score, letter, vowel, consonant, and hook constraints.
- Sort the matches in Rust with deterministic tie breakers.
- Return one ordered array for JavaScript to render.
Patterns support literal letters, ? for exactly one character, and * for zero or more characters. The engine uses a small iterative glob matcher rather than compiling a regular expression for each candidate. It tracks the latest star position and backtracks through bytes only when necessary.
Sorting stays on the Rust side too. Options include score, length, alphabetical order, high-value tiles, bingo candidates, pattern strength, and several hook rankings. Hook values are calculated once per matching word before the sort rather than repeatedly inside the comparison function.
Keeping filtering and sorting together also prevents a subtle architectural failure: returning a huge intermediate result to JavaScript only to filter and reorder it again.
Why Rust and WASM instead of traditional JavaScript?
JavaScript could implement this tool. Modern JavaScript engines are extremely capable, and I would not claim that equivalent Rust/WASM code is automatically faster in every browser or for every query. I have not included a benchmark here because a useful comparison would need equivalent implementations, representative warm and cold loads, and several devices—not a stopwatch around two different algorithms.
WASM still changes the engineering in several useful ways.
Compact, predictable search structures
The hot path works with fixed arrays such as [u8; 26], explicit integer fields, and Rust hash maps. These structures fit letter-counting and membership operations naturally. Rust also lets the compiler enforce the types crossing the internal search pipeline instead of relying on object shapes and conventions.
One engine owns the whole computational pipeline
Indexing, buildability, filtering, scoring, hook detection, analysis, and final sorting live in one module. The UI cannot accidentally implement a slightly different score calculation or repeated-letter rule. Deterministic tie breakers produce stable results.
Performance headroom for worst-case queries
Normal searches touch a subset of compressed shards. Wildcards, union-dictionary searches, and hook operations can expand toward the full 272,405-word index. WASM gives the data-heavy portion a compact compiled execution target while leaving rendering and network orchestration in JavaScript.
Local computation becomes the product architecture
The server distributes static HTML, JavaScript, the WASM binary, and compressed word data. The user's rack does not need a request/response trip to a search API. That removes search-server latency after assets load and makes a precise privacy statement possible: the unscrambling input is processed on the user's device.
Rust is not the reason the tool is private—client-side architecture is. Rust/WASM makes that client-side architecture practical for a richer search engine.
The WASM boundary is not free
WASM has costs, and pretending otherwise makes the architecture less useful to discuss.
- The browser must download and instantiate another binary.
- Dictionary records cross from JavaScript into WASM when a shard is indexed.
- Results cross back into JavaScript for rendering.
- The current engine is synchronous and runs on the main browser thread, so a sufficiently broad query can still block the UI.
- Generated bindings and the
.wasmasset must be versioned together. - Debugging spans JavaScript, generated glue, and Rust.
I reduce boundary overhead by making coarse calls. JavaScript passes a complete shard for additive indexing or one complete search request; it does not call into WASM once per dictionary word. Rust returns the final ordered results rather than exposing its internal maps.
The release build also favors a smaller artifact with link-time optimization, one code-generation unit, size optimization, panic aborts, and stripped symbols.
If broad searches become perceptibly expensive on lower-powered devices, moving the synchronous WASM engine into a Web Worker is the obvious next architectural step. WASM does not automatically move computation off the main thread.
Privacy without a backend—and without vague claims
The page still makes normal network requests to download its assets. “Runs locally” does not mean “the website never uses the network.” It means the rack, pattern, filters, search history, saved words, and notes are not sent to a word-search service.
History and the Pick List use browser localStorage. Dictionary shards are static files. Search and analysis happen in WASM. Definition buttons open third-party dictionary sites only when the user chooses them.
That distinction is important. Privacy claims should describe the data flow, not rely on a reassuring adjective.
Was WASM worth it?
For a tiny anagram demo, probably not. A plain JavaScript array and a few loops would be easier to ship.
For this tool, the search engine grew to include two selectable word lists, more than 272,000 unique words, wildcards, glob patterns, multiplicity-aware filters, dictionary-specific hooks, strategic rankings, scoring, entropy, and board-fit analysis. At that point, a typed computational core with explicit indexes and a narrow browser interface became valuable.
The result is not “a Rust app with some JavaScript around it.” It is a browser application in which each language owns the work it suits best:
- JavaScript handles the web platform.
- Rust handles the search engine.
- WASM is the contract between them.
You can try the Word Unscrambler with a rack like RETAINS, add a pattern such as / ??A?E, or explore dictionary-aware hooks and strategic sorts. The Rust engine source is available on GitHub if you want to inspect the implementation.
If you have built a search-heavy browser tool with WASM—or deliberately kept one in JavaScript—I would be interested to hear where you drew the boundary.
Top comments (0)