Embed the engine in a Worker
Embedding puts the search engine in the same Worker as your application. The index is loaded into the isolate’s WebAssembly memory on the first request, and every search after that is a function call: no HTTP request leaves the isolate.
This guide uses the package @ferroseek/engine from packages/ferroseek/. The example shop in examples/shop-worker/ is a complete Worker built this way, and the code below follows it.
Add the package to your Worker
Section titled “Add the package to your Worker”The packages are not published to npm. The simplest way to use them today is to keep your Worker inside this repository: every directory under examples/ is an npm workspace, so a new Worker there can depend on the packages by name.
{ "name": "my-worker", "private": true, "type": "module", "scripts": { "dev": "wrangler dev" }, "dependencies": { "@ferroseek/engine": "*" }}Run npm install at the repository root afterwards, and npm run wasm:build once so that packages/ferroseek/wasm/ferroseek.wasm exists.
Configure Wrangler
Section titled “Configure Wrangler”The Worker needs three things in its Wrangler configuration:
- a rule that imports
.wasmfiles as compiled WebAssembly modules; - a static-assets directory that holds the index parts, bound as
ASSETS; run_worker_firstfor the index path, so the parts are only reachable through the binding and are never served to the public.
{ "name": "my-worker", "main": "src/index.ts", "compatibility_date": "2025-09-01", "assets": { "directory": "./public", "binding": "ASSETS", "run_worker_first": ["/search-index/*"] }, "rules": [{ "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }]}If the Worker also uses the official Elasticsearch client (see Connect with the client), add "compatibility_flags": ["nodejs_compat"]: the client imports node:* modules. The engine on its own does not need it.
Put the index in the assets directory
Section titled “Put the index in the assets directory”Build an index (see Build and update the index) and copy it under the assets directory. The example shop keeps it in public/search-index/; the root script npm run shop:index copies worker/assets/index/ there:
public/└── search-index/ ├── manifest.json ├── part-000.bin ├── part-001.bin └── part-002.binEach part is at most 20 MiB, below the 25 MiB per-file limit of Workers static assets.
Create the engine once per isolate
Section titled “Create the engine once per isolate”createFerroseek returns an object with a standard fetch(request) method. Create it once, at module scope or lazily on the first request, and reuse it: the index is loaded by the first request that needs it, and concurrent first requests share that one load.
import wasm from "@ferroseek/engine/wasm";import { assetsIndexSource, createFerroseek, type Ferroseek } from "@ferroseek/engine";
export interface Env { readonly ASSETS: Fetcher;}
let engine: Ferroseek | undefined;
export function getEngine(env: Env): Ferroseek { engine ??= createFerroseek({ wasm, source: assetsIndexSource(env.ASSETS, "search-index"), onError: (error, phase) => console.error(`search ${phase} failure`, error), }); return engine;}assetsIndexSource(env.ASSETS, "search-index") reads search-index/manifest.json and the part files through the assets binding. The library never logs on its own; pass onError to see load failures and engine traps.
Send it Elasticsearch requests
Section titled “Send it Elasticsearch requests”The engine answers the same REST routes as the standalone Worker. Build a Request with any origin (only the path and query string are read) and an Elasticsearch request body:
import { getEngine, type Env } from "./search.ts";
export default { async fetch(request: Request, env: Env): Promise<Response> { const q = new URL(request.url).searchParams.get("q") ?? ""; const response = await getEngine(env).fetch( new Request("http://search/products/_search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: { match: { name: q } }, size: 24, }), }), ); return new Response(response.body, response); },} satisfies ExportedHandler<Env>;The response is exactly what Elasticsearch would return, including the status code and the error envelope on failure. In application code you will usually not build these requests by hand: hand the engine to the official client instead, which gives you typed requests and responses and lets you switch to a remote server later. See Connect with the client.
Warm the index up
Section titled “Warm the index up”The first request in a fresh isolate loads the index before it is answered. To load it earlier, call ready(), which resolves with load statistics:
const stats = await getEngine(env).ready();// { loadMs, indexBytes, docCount }If loading fails, the request that triggered it answers 503 index_unavailable_exception, and the next request tries again; a failed load is not cached. If the engine traps while answering a request, that request answers 500 and the instance is dropped, so the next request loads a fresh one.
Memory
Section titled “Memory”A Worker isolate has 128 MB of memory. The 100k-product sample index is 58.3 MiB, and an isolate that has loaded it uses about 62 MB. Your application shares what is left, and each search request may take up to roughly 16 MiB of extra engine heap (see Limits and differences). WebAssembly memory never shrinks, so plan for the high-water mark, not the average.
The loader streams each part straight into WebAssembly memory instead of holding a whole part in JavaScript, so loading does not need a second copy of the index.
Options
Section titled “Options”| Option | Type | Meaning |
|---|---|---|
wasm |
WebAssembly.Module |
The compiled engine: import wasm from "@ferroseek/engine/wasm" |
source |
IndexSource |
Where the index parts come from, e.g. assetsIndexSource(env.ASSETS, "index") |
auth |
AuthConfig |
Require Basic or ApiKey credentials. Omit when embedded |
diagnostics |
boolean |
Add x-engine-time-us, x-wasm-memory-bytes and, on the loading request, x-index-load-ms |
onError |
(error, phase) => void |
Receives load failures ("load") and engine traps ("request") |
The full API is listed in Library API.