Elasticsearch-compatible searchthat runs inside oneCloudflare Worker.
Ferroseek is a search engine for product catalogs of up to about 100,000 items. Written in Rust and compiled to WebAssembly, it keeps the whole index in the Worker's memory: no cluster, no separate search service. Your application talks to it with the official Elasticsearch client.
A proof of concept. The packages are not published to npm.
Search the demo catalog from this page
Every pause in typing sends a real Elasticsearch request from your browser to the demo store's Worker, which holds 100,000 products in memory. The round trip below is measured here, network included.
{
"query": {
"bool": {
"should": [
{
"multi_match": {
"query": "espresso machine",
"type": "phrase_prefix",
"fields": ["name^3", "description"],
"max_expansions": 50,
"boost": 4
}
},
{
"multi_match": {
"query": "espresso machine",
"fields": ["name^3", "description"],
"operator": "and",
"boost": 2
}
}
],
"minimum_should_match": 1
}
},
"size": 6,
"_source": [
"sku",
"name",
"brand",
"category",
"price",
"list_price",
"rating",
"reviews",
"image"
],
"track_total_hits": true,
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 4
}
}
}
}A demonstration catalog: products, brands and prices are generated. Product photos are illustrative: free-licence images shared between similar products, which may show a different product or a real brand that has no connection to the demo. Photo credits
One Worker. The index is already in memory.
There is no search service to call. The engine and the index live in the same Cloudflare Worker isolate that receives the request, so a search is answered from memory where it arrives.
In
An Elasticsearch request
POST /products/_search
From the official client, curl, or a function call when embedded.
Cloudflare Worker isolate
- Your Worker codeOptional. Embedded, the engine is a library in your Worker; standalone, a dedicated Worker serves the routes over HTTP.
- Ferroseek engineRust compiled to WebAssembly. BM25 over the standard analyzer, filters, facets, sorting.
- The index, in WebAssembly memory58.3 MiB for the 100,000-product sample, loaded from static assets on the first request.
Out
An Elasticsearch 7.17 response
hits, aggregations, error envelopes
With the X-Elastic-Product header the official clients check for.
Loaded once per isolate
The index is built offline by a native Rust tool into part files of at most 20 MiB, deployed as Workers static assets. A new isolate copies them into WebAssembly memory on its first request; every request after that is a search over memory, with no network hop.
Elasticsearch on the wire
_search, _count, _doc, _mapping and _msearch take and return Elasticsearch 7.17 JSON. Ranking is BM25 with Lucene's one-byte length norms, so hits come back in the same order with scores within 1e-4.
Unsupported means rejected
A feature Ferroseek does not implement is answered with a 400 in Elasticsearch's error envelope that names the feature. It is never silently ignored, so a query cannot quietly mean something else.
What a product listing page needs
- Full-text relevance
match,multi_matchwith field boosts - Typo tolerance
fuzzinessonmatchandmulti_match - Phrases and prefixes
match_phrase,match_phrase_prefix - Filters
bool,term,terms,range,exists - Facets with counts, multi-select
terms,range,histogramwithpost_filter - Sorting and pagingseveral sort keys,
from/size,search_after
The full list is in the reference: queries and aggregations and facets.
Move to Elasticsearch by changing a URL.
Application code gets the official Elasticsearch JavaScript client from one function. Where it points is an environment variable, so outgrowing Ferroseek is a configuration change, not a rewrite.
import wasm from "@ferroseek/engine/wasm";
import { assetsIndexSource, createFerroseek } from "@ferroseek/engine";
import { createSearchClient, searchConfigFromEnv } from "@ferroseek/client";
// SEARCH_URL unset: the engine runs inside this Worker.
// SEARCH_URL set: a standalone Ferroseek Worker or an Elasticsearch node.
const { mode, config } = searchConfigFromEnv(env as unknown as Record<string, unknown>, () =>
createFerroseek({ wasm, source: assetsIndexSource(env.ASSETS, "search-index") }),
);
// The official @elastic/elasticsearch client, whichever backend was chosen.
const client = createSearchClient(config);
const result = await client.search({
index: "products",
query: { match: { name: "wireless speaker" } },
size: 24,
});
SEARCH_URLunset- The engine embedded in your Worker. A search is a function call.
SEARCH_URL=https://search.example.com- A standalone Ferroseek Worker over HTTP, optionally with Basic or API key authentication.
SEARCH_URL=https://es.example.comandSEARCH_API_KEY- A real Elasticsearch node holding the same index.
Tested, not assumed. The swap test runs the example shop three times with byte-identical sources, changing only these variables. It replays 17 listing URLs (searches, category pages, multi-select facets, sorting, paging) and requires the JSON responses to be identical apart from two timing fields. The swap test
Measured on Cloudflare, with the conditions attached.
These are measurements of the proof of concept with the generated 100,000-product sample catalog, not guarantees. Your catalog, queries, traffic and region will give different numbers.
- Median
- 1.4 ms
- 75th percentile
- 2.1 ms
- 99th percentile
- 8.3 ms
| Measure | Result | Conditions |
|---|---|---|
| Round trip, median | about 19 to 20 ms | Production, from a client about 10 ms of network away. Most of it is network. |
| Index load, cold start | 226 to 313 ms | Production. The three part files stream in concurrently. |
| First request in a new isolate | about 0.84 to 1.35 s | Production, including the index load. |
| Memory after load | about 62 MB of 128 MB | Production. |
| Index size | 58.3 MiB in 3 files | On disk, deployed as static assets. |
| Faceted search, engine time | about 0.3 to 0.4 ms | Native, locally: text, 2 filters, 6 aggregations, sort, 24 hits. |
Checked against a real Elasticsearch 7.17.4
Whatever Elasticsearch 7.17.4 answers is treated as correct. The same sample catalog goes into both, and the repository's suites compare the answers request by request.
- 106reference searchesQueries, filters, aggregations, sorting and paging: same hits in the same order, same aggregation buckets and counts, scores within 1e-4.
- 55feature searchesPhrases, phrase prefixes, typo tolerance, date_histogram, search_after, _msearch and more.
- 24error casesMalformed or invalid requests answer with the same status and error type.
- 11routes/, _mapping, _doc found and not found, _count, an unknown index.
- 65hostile requestsOverflowing intervals, millions of buckets, very deep or wide queries, oversized bodies: each rejected with the expected error or kept under a response-size bound, and the engine still answers correctly afterwards.
- 3×the swap testThe same application code returned identical output, apart from two timing fields, from the embedded engine, a standalone Ferroseek Worker and real Elasticsearch.
- 2official client linesThe unmodified Elasticsearch JavaScript clients, 7.17 and 8.x, make the same calls against both servers, including three that fail.
What it is not.
Ferroseek does one job: product search for a catalog that fits in a Worker. Here is where that stops, so you can rule it out quickly if it does not fit.
- Not for large catalogs
- It is built and measured for catalogs of up to roughly 100,000 products. The whole index lives in an isolate with 128 MB of memory: the sample index takes it to about 62 MB, and each search may use up to roughly 16 MiB more.
- Not writable at runtime
- There is no indexing API, no cluster and no replication. You build the index offline from your catalog, deploy it with the Worker, and rebuild and redeploy when the catalog changes. Build and update the index
- Not all of Elasticsearch
- It implements the subset of Elasticsearch 7.17 a product listing needs.
highlight,suggest,wildcard,query_string,function_score, writes, scroll and others are rejected. Some limits are stricter: a 256 KiB request body, no date math such asnow-7d, no search options in the URL. Rejected features - Not a finished product
- It is a proof of concept. The packages are not published to npm and the source repository is not public. The numbers on this page describe this proof of concept on a generated catalog.
- Not on the Workers Free plan
- The Free plan's 10 ms CPU limit per request is too small to load the index. It needs the Workers Paid plan.
- Not score-identical in every case
- With typo tolerance, a query whose words expand to the same dictionary term can score slightly differently from Elasticsearch, so hits with close scores may change places. The case is documented and in the test corpus.
What it would cost to run, roughly.
Ferroseek has no price of its own; you pay Cloudflare for the Worker. An estimate from Cloudflare's published Workers pricing and the measured CPU time per search.
The Workers Paid plan is $5.00 a month and includes 10 million requests and 30 million CPU milliseconds. Beyond that, requests cost $0.30 per million and CPU time $0.02 per million milliseconds. Requests for static assets, where the index files live, are free.
At 1.4 to 2 ms of CPU per search (the measured median is 1.4 ms), 10 million searches a month fit inside the included allowance.
Prices: Cloudflare Workers pricing. Check the current page before you plan with them.
| Searches a month | CPU time | Estimate |
|---|---|---|
| 10 million | 20 million ms | $5.00included |
| 25 million | 50 million ms | $9.90 |
| 50 million | 100 million ms | $18.40 |
| 100 million | 200 million ms | $35.40 |