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.

    Results appear here as you type when JavaScript is on. Without it, Search opens the same query in thedemo store.

    POSTdemo.ferroseek.com/es/products/_search
    {
      "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
          }
        }
      }
    }
    Status: example requestMatches: Time: Query tier:

    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

    1. Your Worker codeOptional. Embedded, the engine is a library in your Worker; standalone, a dedicated Worker serves the routes over HTTP.
    2. Ferroseek engineRust compiled to WebAssembly. BM25 over the standard analyzer, filters, facets, sorting.
    3. 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.

    Where a search runs. The rest of the 128 MB is shared by your application and request-time working memory, which the engine keeps to roughly 16 MiB per request.

    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 relevancematch, multi_match with field boosts
    • Typo tolerancefuzziness on match and multi_match
    • Phrases and prefixesmatch_phrase, match_phrase_prefix
    • Filtersbool, term, terms, range, exists
    • Facets with counts, multi-selectterms, range, histogram with post_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.

    src/search.tsfrom examples/shop-worker
    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_URL unset
    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.com and SEARCH_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
    CPU time per search request in production on Cloudflare, from Cloudflare's analytics over about 2,800 mixed search requests. CPU time is what the Worker spent computing; it excludes time waiting on the network.
    Other measurements and their conditions
    MeasureResultConditions
    Round trip, medianabout 19 to 20 msProduction, from a client about 10 ms of network away. Most of it is network.
    Index load, cold start226 to 313 msProduction. The three part files stream in concurrently.
    First request in a new isolateabout 0.84 to 1.35 sProduction, including the index load.
    Memory after loadabout 62 MB of 128 MBProduction.
    Index size58.3 MiB in 3 filesOn disk, deployed as static assets.
    Faceted search, engine timeabout 0.3 to 0.4 msNative, 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 as now-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.

    Estimated monthly cost of search alone, at 2 ms of CPU per search
    Searches a monthCPU timeEstimate
    10 million20 million ms$5.00included
    25 million50 million ms$9.90
    50 million100 million ms$18.40
    100 million200 million ms$35.40
    An estimate, not a quote. It assumes one request per search at 2 ms of CPU, between the measured median (1.4 ms) and 75th percentile (2.1 ms). It leaves out your application's own work, cold starts and other Cloudflare products. A search box that retries with typo tolerance, as the demo does, can send up to three requests per search.

    Search the demo store, then read how it is built.