The Architecture Trade-Off Behind Every Elasticsearch-to-Meilisearch Migration
Source: lobsters
The migration pattern has become familiar: someone running Elasticsearch on a small VPS watches it consume 2 GB of RAM before a single query has been issued, swaps in Meilisearch, and finds things working better with a tenth of the memory. This migration account follows exactly that arc. The experience is real, but the explanations accompanying these stories often stop at “Elasticsearch is bloated, Meilisearch is lean,” which misses the architectural reasons those differences exist and obscures where the trade-offs actually land.
The full picture matters because the same design choices that make Meilisearch fast and cheap on small infrastructure also set hard limits on what you can build with it.
The JVM Is Not Incidental
Elasticsearch’s resource consumption is not a bug or a configuration failure. It is a consequence of building on the JVM and Apache Lucene, both of which were designed for large-scale, distributed, long-running workloads.
Lucene handles data through segment files written to disk. When you index documents, Lucene writes small in-memory buffers to disk as new segments, then merges them in the background. Each merge is a read-then-rewrite cycle, meaning disk write amplification is baked into the design. The refresh_interval setting controls how often in-memory segments are flushed to a searchable state (default: 1 second). Setting it to -1 during bulk indexing dramatically improves throughput not because of any trick, but simply because it removes the per-second flush overhead. The cost is that newly indexed documents are not searchable until a manual refresh.
The JVM heap adds another layer. Elasticsearch’s own documentation recommends setting heap to half of available RAM, but with a hard ceiling around 31 GB. The reason: the JVM uses compressed ordinary object pointers (CompressedOOPs) to represent heap references in 4 bytes instead of 8, which works up to a heap of approximately 32 GB. Cross that boundary and the JVM silently switches to full 64-bit pointers, doubling per-object reference overhead and often increasing effective memory usage by 20-30% on heap-heavy workloads. It surfaces in production when you have already committed to the hardware.
G1GC, the default garbage collector since Elasticsearch 7.x, handles most collection concurrently, but under heavy indexing or large bucket aggregations, stop-the-world pauses of several hundred milliseconds are realistic. On a cluster doing mixed read and write traffic, this appears as occasional query latency spikes that are difficult to attribute without GC logging enabled.
None of this makes Elasticsearch bad. It makes it expensive to operate at small and medium scale, where the cluster overhead and JVM tuning expertise do not pay off.
What Meilisearch Puts in Its Place
Meilisearch is written in Rust and ships as a single binary around 50 MB. Its storage backend is LMDB (Lightning Memory-Mapped Database), a B-tree key-value store that uses mmap for reads. The OS page cache handles caching without requiring explicit heap allocation. In practice, Meilisearch idles at 50-150 MB of RSS on a fresh install with a small index, compared to the hundreds of megabytes to several gigabytes a JVM-based Elasticsearch node requires before serving any traffic.
The inverted index that Meilisearch builds on top of LMDB relies on roaring bitmaps to store position lists, implemented via the roaring Rust crate. Roaring bitmaps compress sorted integer sets by storing them in chunks based on the high 16 bits of each value, switching between array and bitmap containers depending on density. For a search index where word-to-document position mappings make up the largest component of storage, this compression ratio directly affects both disk usage and cache efficiency.
Indexing in Meilisearch is asynchronous and returns a task ID immediately. The actual index update happens inside a write transaction against LMDB. LMDB serializes write transactions, meaning only one write transaction can proceed at a time per environment. This is why Meilisearch’s indexing throughput on large datasets lags behind Elasticsearch’s parallel bulk endpoint. On an initial load of tens of millions of documents, this constraint shows up clearly.
The Ranking Pipeline vs. BM25
Elasticsearch defaults to BM25 for relevance scoring, with two tunable parameters: k1 (term frequency saturation, default 1.2) and b (field length normalization, default 0.75). BM25 produces a continuous score that can be modified further with function_score queries, decay functions, field boosts, or Painless scripting. The result is extremely flexible, but the number of knobs also means the default behavior on novel content often requires domain-specific tuning to feel correct.
Meilisearch uses a deterministic multi-criteria ranking pipeline applied in strict order. The default sequence is words, typo, proximity, attribute, sort, exactness. Each criterion acts as a tiebreaker for the previous one. The system does not produce a single continuous score; it produces a ranked list where each stage refines the set of ties left over from the last.
The practical effect is that Meilisearch’s out-of-the-box relevance tends to satisfy the common case well, particularly for product catalogs and document search, without tuning. You configure which attributes are searchable and their priority order, and proximity and typo handling do the rest. What you cannot express is something like “this document should score higher because it was published recently and has a high view count.” You can approximate that by pre-computing a composite score at index time and using the sort criterion, but it is a different model with different trade-offs than BM25 function scoring.
Typo Tolerance as a First-Class Feature
Meilisearch builds typo tolerance into every query using a DFA (Deterministic Finite Automaton) constructed from the Levenshtein distance, using an approach derived from the Schulz-Mihov technique for compiling edit-distance constraints into automata. Typo matching is not a separate query mode or a performance penalty; it runs as part of the normal index lookup.
The distance thresholds are calibrated by word length: words of 1-4 characters receive no tolerance, 5-8 characters allow one edit, and 9 or more characters allow two. These thresholds are configurable via typoTolerance.minWordSizeForTypos in the Settings API. The result is that “headphons” matches “headphones” without any extra query configuration, which is a meaningful ergonomic advantage over Elasticsearch’s fuzziness parameter, which requires an explicit match query with "fuzziness": "AUTO" and does not apply by default.
What You Are Actually Giving Up
Meilisearch has no aggregation framework. You get facet counts (document counts per value of a filterable attribute) but no histograms, no date range buckets, no sum or average metrics, no nested aggregations. If your application uses search results to power a dashboard or any analytics display, that functionality must come from a different system.
There are no nested documents. Meilisearch indexes flat JSON. Arrays of objects can be stored, but each document is treated as a flat entity. You cannot express “find products where at least one variant has price < 50 and size = 'L'” with the correctness guarantees that Elasticsearch’s nested type provides, because Meilisearch has no notion of sub-document scope during filtering.
Clustering is absent in the open-source binary. The Meilisearch project has been working toward it, with v1.6 shipping an experimental network feature, and their managed cloud product handles horizontal scaling. But the self-hosted path remains single-node, which puts a practical ceiling on dataset size somewhere in the tens to low hundreds of millions of documents, depending on hardware.
Zero-downtime re-indexing is possible but requires deliberate tooling. Meilisearch has no index alias system equivalent to Elasticsearch’s alias API. The supported pattern since v1.10 is the indexSwap task: build a new index under a temporary name, verify it, then call POST /swap-indexes to atomically exchange the two names. This is workable, but it is explicit plumbing your application must manage rather than a transparent alias layer.
When the Split Architecture Makes Sense
A common resolution for teams that have both search and analytics requirements is to keep Elasticsearch, OpenSearch, or ClickHouse for analytics while adopting Meilisearch for user-facing search. Documents are written to both systems, search queries go to Meilisearch, and aggregation queries go to the analytics store. This adds operational complexity but lets each system operate where it is strongest.
For products where the search interface is the primary use case, the dataset is under a few tens of millions of documents, and there is no analytics requirement tied to search queries, Meilisearch’s simplicity translates directly into lower infrastructure cost and less operational burden. The engineering effort shifts from tuning Elasticsearch mappings and JVM flags to configuring Meilisearch’s settings once and not touching them again.
The architectural choice ultimately comes down to which constraints you are willing to accept. LMDB and Rust give you predictable memory use and straightforward deployment at the cost of serialized write transactions and no clustering. Lucene and the JVM give you aggregations, complex query semantics, and multi-node scale at the cost of heap sizing, garbage collection tuning, and a substantially higher floor for what “running it” requires. Neither system is misconfigured; they are designed for different operating points, and the right migration decision follows from which operating point matches your workload.