16 min read
Meilisearch replaced my Elasticsearch cluster for $14/month. A practical, honest guide to what it does well, where it falls short, and how to build real search with it.
For three years I ran Elasticsearch on a side project that had exactly 200,000 documents and about 800 daily active users. Three years. One cluster. Two nodes. Somewhere north of $120/month in hosting costs. It could handle everything I needed — full-text search, faceted filters, typo tolerance — but operating it felt like maintaining a small spacecraft. JVM heap tuning. Index mapping migrations. Cluster state corruption the one time I ran a snapshot at the wrong moment. Yellow cluster health that would persist for forty minutes and then inexplicably resolve itself.
I kept telling myself it was the right tool. It's what serious engineers use. It scales to petabytes. My documents were about cookbooks.
Then I spent one afternoon replacing it with Meilisearch. My p99 search latency went from 180ms to 12ms. My infrastructure bill dropped to $14/month. My configuration went from 340 lines of YAML to about 30. And the typo tolerance — which I had always struggled to tune properly in Elasticsearch — just worked, out of the box, without me understanding why.
I want to write the article I wish I'd found three years ago: a clear-eyed, technically honest look at what Meilisearch is, what it does exceptionally well, where it genuinely falls short, and how to build something real with it.
Meilisearch is an open-source search engine written in Rust, built by a Paris-based company of the same name. The project has accumulated over 47,000 GitHub stars as of mid-2024, making it one of the most-starred search-related repositories on the platform. The first stable release was in 2019, and the engine has matured significantly since then — v1.0 shipped in March 2023, signaling production-readiness.
The core thesis is deliberate constraint: Meilisearch is not trying to be Elasticsearch. It makes specific tradeoffs to optimize for one use case — the kind of search that happens when a user types into a search bar in your application — and refuses to compromise that experience for generality.
Those tradeoffs manifest in four defining design decisions.
Meilisearch stores everything in memory-mapped files using LMDB, a high-performance embedded key-value store originally built for the OpenLDAP project. This is why search is fast: the search index lives in memory (logically, even if not entirely physically), and query latency doesn't involve disk I/O in the hot path. The tradeoff is that your index size cannot vastly exceed your available RAM — more on this in the caveats section.
Typo tolerance is first-class and automatic. Meilisearch uses a modified BK-tree data structure to compute edit distance (Levenshtein distance) at query time. It allows one typo for words between 5 and 8 characters, and two typos for words 9 characters or longer. This is not configurable at the per-query level; it's a deliberate product decision that the engine knows better than you do about acceptable typo thresholds. In my experience this decision is correct about 95% of the time.
Ranking is rule-based, not ML-based. Meilisearch applies a deterministic cascade of ranking rules in order: typo distance, geographic distance (if you're using geosearch), number of words matched, exact match quality, word proximity, attribute ranking, and finally a custom ranking expression if you define one. There is no BM25, no TF-IDF as a primary signal, no learning-to-rank pipeline. For most application search use cases this produces better results than you'd expect; for cases where relevance modeling matters deeply (web-scale search, document retrieval with complex semantics), it's a limitation.
The API is REST-only with official SDKs in JavaScript, Python, Ruby, PHP, Go, Rust, Java, Swift, and .NET. Every operation is async at the storage layer — mutations return a task ID, and you poll for completion — but reads are fully synchronous and immediate once the index is in a stable state.
Before jumping into setup, it's worth understanding what a Meilisearch deployment actually looks like, because the mental model is simpler than Elasticsearch but has some subtleties worth getting right.
A single Meilisearch process manages one or more indexes. An index is roughly analogous to a table in a relational database or an index in Elasticsearch. Each index has its own settings: searchable attributes, filterable attributes, sortable attributes, ranking rules, typo tolerance config, and synonym lists. Indexes are independent — there's no cross-index search in a single query (though you can run multiple queries in one HTTP call via the multi-search endpoint).
Documents are JSON objects stored in an index. Every document must have a primary key — a field that Meilisearch uses as its unique identifier. You can declare this explicitly or let Meilisearch infer it by looking for a field named id, uid, objectId, primaryKey, or {indexName}_id. Getting this right matters: if Meilisearch picks the wrong field as primary key, re-indexing is your only recovery.
Tasks are the async operation queue. When you add documents, update settings, or delete an index, Meilisearch enqueues a task and returns a task object with a numeric ID. You can poll GET /tasks/{taskId} to check status, or use waitForTask() in any SDK. In practice, most mutations on small-to-medium indexes complete within milliseconds; the async model is there for correctness guarantees, not because you'll usually be waiting.
The data flow during indexing:
Documents (JSON) → HTTP POST /indexes/{uid}/documents
→ Meilisearch parses, extracts fields
→ Builds inverted index + field storage
→ Task completes (status: succeeded)
→ Documents immediately searchable
There's no replica configuration, no shard allocation, no node topology to understand for a single-instance deployment. The entire operational surface is one process, one data directory.
The fastest path to a running instance:
# Docker (recommended for local dev)
docker run -it --rm \
-p 7700:7700 \
-v $(pwd)/meili_data:/meili_data \
getmeili/meilisearch:v1.9 \
meilisearch --master-key="your-master-key-here"
Or install the binary directly on Linux:
curl -L https://install.meilisearch.com | sh
./meilisearch --master-key="your-master-key-here"
The --master-key flag is critical. Without it, Meilisearch runs in development mode with no authentication — fine for local work, a significant security risk in any other context. The master key must be at least 16 bytes (UTF-8). Once set, it grants full API access; you'll generate scoped API keys from it for your application.
Meilisearch runs on port 7700 by default. Hit http://localhost:7700/health and you'll get {"status":"available"} when it's ready.
Let's work with a real example: a recipe search API. Each document looks like this:
{
"id": 1,
"title": "Spaghetti alla Carbonara",
"description": "A classic Roman pasta dish with eggs, Pecorino Romano, guanciale, and black pepper.",
"cuisine": "Italian",
"difficulty": "medium",
"prep_time_minutes": 15,
"cook_time_minutes": 20,
"tags": ["pasta", "roman", "eggs", "classic"],
"rating": 4.8,
"published_at": 1706745600
}
Using the JavaScript SDK (npm install meilisearch):
import { MeiliSearch } from "meilisearch";
const client = new MeiliSearch({
host: "http://localhost:7700",
apiKey: "your-master-key-here",
});
// Create or update the index
const index = client.index("recipes");
// Add documents — returns a task
const task = await index.addDocuments(recipes);
// Wait for indexing to complete
await client.waitForTask(task.taskUid);
console.log("Indexed", recipes.length, "documents");
Once documents are indexed, a basic search takes one line:
const results = await index.search("carbonara");
console.log(results.hits); // Array of matching documents
The response includes hits (matched documents), query (the original query string), processingTimeMs (engine-side latency), limit, offset, and estimatedTotalHits. That last field is an estimate, not a precise count, for performance reasons — getting an exact count requires a full index scan.
The default settings work for a demo. Production search requires explicit configuration of three things: which fields are searchable, which fields are filterable, and which fields are sortable.
await index.updateSettings({
// Only search in these fields, in priority order
searchableAttributes: [
"title",
"description",
"tags",
"cuisine",
],
// These fields can be used in filter expressions
filterableAttributes: [
"cuisine",
"difficulty",
"tags",
"rating",
],
// These fields can be used in sort expressions
sortableAttributes: [
"rating",
"prep_time_minutes",
"cook_time_minutes",
"published_at",
],
// Ranking rules — this is the default cascade, shown explicitly
rankingRules: [
"words",
"typo",
"proximity",
"attribute",
"sort",
"exactness",
],
// Synonyms
synonyms: {
"pasta": ["noodles", "spaghetti", "penne"],
"quick": ["fast", "easy", "simple"],
},
// Stop words — excluded from search
stopWords: ["the", "a", "an", "and", "or", "of"],
});
A critical operational note: filterableAttributes and sortableAttributes require re-indexing. When you add a new field to either list, Meilisearch rebuilds the relevant index structures for every document in the index. On a large dataset this can take minutes and consumes significant CPU. Plan schema changes during low-traffic windows.
The searchableAttributes order matters for ranking. Fields listed earlier are weighted more heavily in the attribute ranking rule. Putting title before description means a match in the title ranks higher than an equivalent match in the description, which is almost always the right call.
Meilisearch's filter syntax is a clean expression language that supports comparison operators, IN, NOT, AND, OR, and EXISTS:
// Boolean filter: Italian cuisine, medium or easy difficulty
const results = await index.search("pasta", {
filter: 'cuisine = "Italian" AND (difficulty = "easy" OR difficulty = "medium")',
});
// Range filter: highly rated quick recipes
const quickHighRated = await index.search("chicken", {
filter: "rating >= 4.5 AND prep_time_minutes <= 20",
});
// Array filter: recipes with specific tags
const tagged = await index.search("soup", {
filter: 'tags IN ["vegan", "gluten-free"]',
});
// Sorting: newest first, then by rating
const sorted = await index.search("dessert", {
sort: ["published_at:desc", "rating:desc"],
});
Faceted search — getting counts of documents per attribute value alongside results — is where Meilisearch's UX focus really shows:
const faceted = await index.search("italian", {
facets: ["cuisine", "difficulty", "tags"],
filter: "rating >= 4.0",
});
console.log(faceted.facetDistribution);
// {
// cuisine: { Italian: 142, French: 38, Japanese: 27 },
// difficulty: { easy: 89, medium: 78, hard: 40 },
// tags: { pasta: 55, "gluten-free": 32, ... }
// }
These counts are returned in the same round trip as your search results, with no additional query. Elasticsearch requires aggregations for this, which are more expressive but also more expensive to configure correctly. Meilisearch's facets are simpler and cover 90% of use cases.
Meilisearch has first-class geosearch support through a reserved field called _geo. If your documents include location data, add the field during indexing:
{
"id": 42,
"name": "Trattoria da Mario",
"cuisine": "Italian",
"_geo": {
"lat": 41.8902,
"lng": 12.4922
}
}
Then make _geo a filterable attribute and use the built-in geo filter functions:
// Restaurants within 5km of a point
const nearby = await index.search("pizza", {
filter: "_geoRadius(41.8902, 12.4922, 5000)", // lat, lng, radius in meters
sort: ["_geoPoint(41.8902, 12.4922):asc"], // Sort by distance
});
// Bounding box search
const inArea = await index.search("", {
filter: "_geoBoundingBox([45.5, -73.5], [45.4, -73.6])", // [topRight], [bottomLeft]
});
The _geoPoint sort key returns documents with a _geoDistance field in the response showing meters from the reference point — exactly what you need for "X km away" UI labels.
Your master key should never leave your server. For client-facing applications — including frontend JavaScript that calls Meilisearch directly — generate scoped API keys with restricted permissions:
// Server-side: generate a search-only key
const searchKey = await client.createKey({
description: "Frontend search key",
actions: ["search"], // Only allow search operations
indexes: ["recipes"], // Only allow access to this index
expiresAt: null, // No expiry — manage rotation manually
});
console.log(searchKey.key); // Use this in your frontend
For multi-tenant applications where different users should only see their own data, Meilisearch supports tenant tokens — signed JWTs that embed a filter expression that gets applied to every query made with that token:
import { MeiliSearch } from "meilisearch";
// Server-side: generate a per-user tenant token
// This user can only search their own organization's documents
const tenantToken = client.generateTenantToken(
searchKey.uid, // Parent key UID
[{ filter: `organizationId = ${org.id}` }], // Mandatory filter
{
apiKey: searchKey.key,
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24), // 24 hours
}
);
// Client uses this token — searches are automatically scoped
Tenant tokens are the correct pattern for SaaS applications. The filter is cryptographically signed and cannot be overridden by the client, which means users cannot search outside their data even if they craft manual API calls.
One genuinely underrated feature: a single HTTP call that fans out to multiple indexes and returns all results:
const { results } = await client.multiSearch({
queries: [
{
indexUid: "recipes",
q: "chocolate",
limit: 5,
},
{
indexUid: "ingredients",
q: "chocolate",
limit: 5,
},
{
indexUid: "articles",
q: "chocolate cake history",
limit: 3,
},
],
});
// results[0] = recipe hits
// results[1] = ingredient hits
// results[2] = article hits
This is how you build a global search experience — the kind where one search bar queries your products, your docs, and your blog posts simultaneously. Each query runs independently and in parallel server-side; the response includes all result sets. No frontend orchestration needed.
Meilisearch offers a managed cloud offering at cloud.meilisearch.com. The pricing is straightforward: plans start at around $30/month for up to 100k documents and 10GB index storage, scaling up from there. The cloud offering handles upgrades, backups, monitoring, and the operational overhead that comes with self-hosting.
For self-hosted deployments, the main operational concern is the data directory. Meilisearch stores its index on disk, and that directory grows with your index size. The recommended approach for production is:
meilisearch \
--master-key="$MEILI_MASTER_KEY" \
--db-path="/data/meilisearch" \
--http-addr="0.0.0.0:7700" \
--log-level="WARN" \
--max-indexing-memory="2 GiB"
--max-indexing-memory caps the RAM used during index rebuilds. Without this, a large re-indexing operation can consume all available memory and trigger the OOM killer. Set it to roughly half your available RAM to leave headroom for the OS and other processes.
Backup is straightforward: Meilisearch has a built-in dump system. A dump is a portable snapshot of all your data and settings that can be restored on any Meilisearch version:
# Trigger a dump via API
curl -X POST http://localhost:7700/dumps \
-H "Authorization: Bearer $MASTER_KEY"
# Import a dump on startup
meilisearch --import-dump /data/meilisearch/dumps/20240715-143022.dump
Automate this with a cron job and ship dumps to object storage (S3, Backblaze B2) for disaster recovery.
I want to be direct about the limitations, because Meilisearch's documentation undersells them.
It is not a general-purpose search engine. Meilisearch has no full SQL-like query capabilities, no aggregations beyond facet counts, no custom scoring functions that operate on arbitrary field expressions, and no machine learning relevance pipeline. If you need to rank results by a complex formula involving multiple numeric fields — say, a product score that weights together ratings, recency, and conversion rate — you're going to fight the engine. Elasticsearch's function score queries or OpenSearch's Learning to Rank plugin have no equivalent here.
Index size is bounded by your infrastructure. The LMDB-backed index performs best when the working set fits in RAM. For very large datasets — tens of millions of documents with many text-heavy fields — Meilisearch becomes expensive to run because you need enough RAM to hold the hot index pages. The engine can handle datasets larger than RAM via memory-mapped I/O and OS page cache management, but query latency will degrade if the index doesn't fit. Elasticsearch's disk-based indexes handle this more gracefully at large scale.
There is no distributed mode. Meilisearch runs as a single process on a single machine. There is no native sharding, no replica configuration, no built-in high availability. For write-heavy workloads or datasets that need to span multiple machines, this is a hard constraint. The company has discussed distributed capabilities on their roadmap, but as of v1.9, it remains a single-node system. For most application search use cases this is irrelevant; for high-write-volume production systems, it's worth knowing.
Vector search is in active development. Meilisearch added hybrid search (combining vector similarity with keyword search) in v1.6, and the feature has improved significantly through subsequent releases. However, the vector search implementation is newer and less mature than dedicated vector stores like Qdrant, Weaviate, or Pinecone. If semantic search is your primary use case rather than keyword search augmented by vectors, evaluate purpose-built vector databases first.
The Meilisearch story is a microcosm of a broader shift in the infrastructure market: the unbundling of complexity. For most of the 2010s, if you needed search, you reached for Elasticsearch — not because its complexity was necessary, but because nothing else existed at production quality. You inherited the JVM, the cluster model, the mapping API, the query DSL, all of it, even if your use case was a search bar on a SaaS product with 50,000 records.
Meilisearch, along with TypeSense (another Rust-based alternative), represents the other side of that bargain: a tool that does less, deliberately, so that what it does do is dramatically simpler to operate and dramatically faster to deliver results. The tradeoffs are real, but for application-layer search — the use case the vast majority of web developers actually have — they're tradeoffs worth making.
The question is not "is Meilisearch better than Elasticsearch." It's the wrong frame. The question is: does your search use case require the general-purpose power of a distributed analytics engine, or does it require fast, typo-tolerant, faceted search over a bounded dataset? Be honest about the answer, and pick accordingly.
Most of us are building cookbook apps.
If you're building any kind of SaaS product, e-commerce site, documentation portal, internal knowledge base, or content-heavy application and you need search — Meilisearch should be your default starting point, not Elasticsearch. You can always migrate to something more complex when your requirements outgrow it. The reverse migration is much harder.
If you're already running Elasticsearch for application search and you're spending more time operating the cluster than building features, the migration is smaller than you think. Meilisearch's REST API is simple enough that a client-side adapter can usually wrap both engines behind a common interface, letting you run them in parallel during transition.
If you're building a data analytics platform, log aggregation pipeline, or anything that needs full-text search across hundreds of millions of documents with complex aggregations — stay on Elasticsearch. It's the right tool for that job.
--master-key set — never in keyless mode outside local devprimary key explicitly on index creation, don't let Meilisearch infer itsearchableAttributes in priority order (most important field first)filterableAttributes and sortableAttributes before adding documents to avoid re-indexing--max-indexing-memory in production to prevent OOM during index rebuildsmultiSearch for federated search across multiple indexes