Full-Stack Questions

Meilisearch & Mixpanel Interview Questions

Search relevance, indexing and security with Meilisearch; events, identity and funnels with Mixpanel.

Lesson 1239 min read#search#analytics

On this page

  1. Words used in this lesson
  2. Part 1: Search with Meilisearch
  3. Why not just use LIKE in the database?
  4. The model: indexes, documents, tasks
  5. Settings that matter
  6. Searching: filters, facets, sort
  7. Several indexes at once
  8. Meaning-based and hybrid search
  9. Security: keys and tenant tokens
  10. Keeping the index in sync with your database
  11. Frontend
  12. Running it
  13. Part 2: Product analytics with Mixpanel
  14. The data model
  15. Identity: distinctid, identify, reset
  16. Setting it up in Next.js
  17. Reading the data: reports
  18. Naming and governance
  19. Privacy, consent and blockers
  20. Choosing a tool (general knowledge, check current details)
  21. Common mistakes
  22. Interview questions
  23. Key takeaways
  24. What's next

In one line: Meilisearch is a search engine you put next to your database so users get fast, typo-tolerant, filterable search. Mixpanel is a product analytics tool that stores events (things users did) so you can answer "where do users drop off?". Search needs a careful sync from your database. Analytics needs a careful identity and naming plan. Both are easy to start and easy to get quietly wrong.

You will meet both in a full-stack interview, usually as "how would you add search to our shop?" and "how would you track the sign-up funnel?". This lesson explains each tool, shows runnable snippets, and then the questions interviewers ask. I ran the code against a real Meilisearch server (version 1.54) and against the real browser and Node Mixpanel libraries with a local capture server, so the numbers below come from real runs.

Words used in this lesson

  • A search engine is a program that keeps its own copy of your data in a shape built for fast text lookup, and ranks the matches by relevance.
  • An index (in Meilisearch) is a named collection of documents with its own settings, like a table with its own rules. A document is one JSON object in an index, like a row. A primary key is the field that uniquely names each document.
  • A typo here means a query word that is spelled a little wrong. Typo tolerance means the engine still finds the right result.
  • A facet is a field you can count and filter by, such as brand. A facet distribution is the list of values with their counts, like "Logi (3), Keyz (1)".
  • A task is a background job the engine creates for each write. Writes are asynchronous: the HTTP call returns at once and the work finishes later.
  • An embedding is a list of numbers that represents the meaning of a text. Hybrid search mixes keyword search with meaning-based (semantic) search.
  • A tenant is one customer of your SaaS product. Multi-tenancy means many tenants share one system and must never see each other's data.
  • A JWT (JSON Web Token) is a signed text token that carries a small JSON payload. Anyone can read it, but only the key holder can sign it.
  • Product analytics means studying what users do inside your product. An event is one thing a user did, with a name, a time, a user id and extra details called properties.
  • A funnel is an ordered list of steps (visit, sign up, buy). The report shows how many users get from each step to the next. Retention shows how many users come back later. A cohort is a saved group of users who share something.
  • PII (personally identifiable information) is data that can identify a person, such as an email or a phone number.
  • GDPR is the European Union privacy law. It says, for example, that you need a legal reason (often consent) to track a person, and that people can ask you to delete their data.

Part 1: Search with Meilisearch

Why not just use LIKE in the database?

WHERE name LIKE '%mouse%' cannot handle typos, cannot rank by relevance, is slow on big tables, and cannot give facet counts cheaply. A search engine is built for exactly these things. The trade-off is a second copy of your data that you must keep in sync.

OptionGood atWeak at
Database full-text search (for example Postgres tsvector)No extra system. Always consistent with your data. Joins and SQL.Typo tolerance needs extra work. Facets and search-as-you-type are harder.
MeilisearchEasy to start. Typo tolerance, filters, facets, hybrid search out of the box. Single binary. Disk-based storage.Not a primary database. Single-node by default (sharding is an Enterprise feature). Smaller analytics features than Elasticsearch.
Elasticsearch / OpenSearchHuge scale, log analytics, aggregations, very flexible.Heavier to run and tune.
TypesenseSimilar goals, fast.Keeps the index in RAM (so size is limited by memory).
AlgoliaMature hosted product, merchandising tools.Closed source, usage-based price, no self-hosting.

The comparison pages written by Meilisearch itself are, of course, friendly to Meilisearch. In an interview, say what each tool is for and state the trade-off, as in the table. Do not say "X is bad".

Licence detail worth knowing: the Community Edition is MIT licensed. Sharding and replication are Enterprise Edition features (a different licence that is not free to use in production).

The model: indexes, documents, tasks

JavaScript
import { Meilisearch } from 'meilisearch';const client = new Meilisearch({ host: 'http://127.0.0.1:7700', apiKey: process.env.MEILI_ADMIN_KEY });await client.createIndex('products', { primaryKey: 'sku' }).waitTask();const task = await client.index('products').addDocuments(docs).waitTask();console.log(task.status, task.details); // succeeded { receivedDocuments: 5, indexedDocuments: 5 }

Three things to understand:

  1. Every write returns a task, not a result. addDocuments answers 202 Accepted with a taskUid and status enqueued. A queue then processes it. waitTask() (or polling GET /tasks/{uid}) tells you when it is succeeded or failed. In my run, a search made straight after enqueueing did not yet see the new document. It appeared after the task succeeded. So search is eventually consistent with your writes.
  2. Failures are in the task, not in the HTTP response. I sent documents with no field ending in id and no primaryKey. The HTTP call succeeded, and the task failed with index_primary_key_no_candidate_found. Always check the task, and always set primaryKey yourself. Other task failures I triggered: a document without the key (missing_document_id) and a key longer than 511 bytes (invalid_document_id).
  3. Add or replace vs update. addDocuments with an existing key replaces the whole document: after sending { sku: 'p1', price: 20 } the document had no name left. updateDocuments merges fields, so { sku: 'p1', price: 22 } kept the name. For sync jobs, merge-style updates are usually what you want. deleteDocuments takes ids or a filter.

Tasks are batched: several tasks may be processed in one batch. The task database is capped (about 20 GiB), and old finished tasks are deleted automatically once it grows large.

Settings that matter

A new index searches every field and filters on none. This is what I read from a fresh index:

TEXT
searchableAttributes ["*"]   filterableAttributes []   sortableAttributes []rankingRules ["words","typo","proximity","attributeRank","sort","wordPosition","exactness"]typoTolerance { enabled: true, minWordSizeForTypos: { oneTypo: 5, twoTypos: 9 } }pagination { maxTotalHits: 1000 }     faceting { maxValuesPerFacet: 100 }
JavaScript
await client.index('products').updateSettings({  searchableAttributes: ['name', 'brand', 'description', 'tags'], // order = importance  filterableAttributes: ['category', 'brand', 'price', 'tags', 'tenantId'],  sortableAttributes: ['price', 'rating'],}).waitTask();
  • searchableAttributes: which fields the query text is matched against. The order sets importance: a match in name beats a match in description. Keep it short: fewer fields means faster indexing and better relevance.
  • filterableAttributes: fields you may use in filter and facets. If you filter on a field that is not listed, Meilisearch refuses: I got invalid_search_filter ("Attribute category is not filterable"). Sorting without sortableAttributes fails the same way (invalid_search_sort).
  • displayedAttributes: which fields come back in results. Hide internal fields here, but remember that anyone with a search key can still filter by a filterable field, so never put secrets in documents.

Changing searchable, filterable or sortable attributes (and stop words, synonyms, typo tolerance, embedders, dictionary) re-indexes every document. Changing ranking rules or displayed attributes does not. So configure settings before loading a big dataset.

How ranking works

Meilisearch sorts results with ranking rules, applied in order like a series of tie-breakers (a "bucket sort"): the first rule splits results into groups, the next rule orders inside each group, and so on.

RulePrefers documents with ...
wordsmore of the query words
typofewer typos
proximityquery words close together
attributeRankmatches in more important fields (from searchableAttributes)
sortonly active when the request has sort
wordPositionmatches near the start of a field
exactnessthe exact words, not prefixes or typo matches

You can add custom rules such as "popularity:desc" (put them last, as business tie-breakers). Moving sort higher makes sorting stricter and relevance weaker. showRankingScore: true returns a score between 0 and 1 for each hit, which is how you debug "why is this on top?". In my run, searching office gave Office Monitor 24" 0.996 and Wireless Mouse 0.708.

Typos, prefixes and the "last word" rule

  • A word needs 5 letters for one typo and 9 letters for two (the defaults above). I saw mouze find Mouse, but mose (4 letters) found nothing.
  • The last query word is treated as a prefix, which is what makes search-as-you-type work: keyb found Keyboard.
  • With the default matchingStrategy: 'last', if not all words match, Meilisearch drops words from the end until something matches. So wirless mose returned Wireless Mouse (it dropped mose). With matchingStrategy: 'all' the same query returned nothing. Choose on purpose: last feels forgiving, all is strict.
  • You can switch typo tolerance off per field (disableOnAttributes, good for SKUs and ids) or for numbers.

Searching: filters, facets, sort

JavaScript
const res = await client.index('products').search('monitor', {  filter: ['tags = gaming OR tags = office', 'price >= 100'], // outer array = AND, inner = OR  sort: ['price:asc'],  facets: ['category', 'brand'],  attributesToHighlight: ['name'],  limit: 20,});// res.hits, res.facetDistribution, res.estimatedTotalHits
  • A filter can be a string (category = accessories AND price < 50) or an array (outer array = AND, inner array = OR). Maximum nesting depth is 200.
  • facets returns counts: in my run {"category":{"accessories":1},"brand":{"Logi":1}}. Counts reflect the current filter. For the classic "count other values while one is chosen" UI, use a separate query (disjunctive facets).
  • Highlighting wraps matches in tags you choose. Escape document content before rendering it (React does this by default; avoid dangerouslySetInnerHTML).

Pagination and its hidden limit

Two styles exist: limit + offset (returns estimatedTotalHits) and page + hitsPerPage (returns exact totalHits and totalPages). The docs recommend "Previous/Next" buttons with limit/offset, because exact page counts cost more.

Search never returns more than maxTotalHits results (default 1000). I indexed 3000 matching documents. offset: 990 returned 10 hits and offset: 1000 returned 0, while the response still reported totalHits: 3000 and totalPages: 150. A page picker would show 150 pages, and pages after 50 would be empty. Fix: raise maxTotalHits (the docs warn that large values slow queries, and values over 20 000 may take seconds), or better, stop users from paging that deep and push them to filter. To read all documents (export, sync check), use the get documents endpoint, which has no such cap: it returned documents at offset 2990 in my test.

Several indexes at once

  • Multi-search sends many queries in one HTTP call and returns one result list per query. Use it for a search page that shows products and articles in separate sections, or for your facet queries.
  • Federated search (federation: {}) merges them into one ranked list, each hit tagged with _federation.indexUid. You can weight an index (federationOptions: { weight: 0.5 }). In my run, two products and one article came back as one list.

Keyword search misses synonyms ("sofa" vs "couch"). Semantic search compares embeddings. In Meilisearch you configure an embedder in settings: it can call a provider (OpenAI, Cohere, Mistral and others, or any REST API), run a local model, or be userProvided (you send the vectors yourself in _vectors). A search then uses hybrid: { embedder, semanticRatio }: 0 is pure keywords, 1 is pure semantic, and values in between mix both inside the engine, in one query.

I ran this with userProvided three-number vectors (no API key needed). With semanticRatio: 1 and a query vector near "space", the results were Space Odyssey, Galaxy Wars, then the rest. With semanticRatio: 0 and the query paris, only the keyword match came back. Hybrid search did not need any experimental flag in this version. Notes:

  • A userProvided embedder expects you to supply the query vector with the search (vector: [...]).
  • Embedder changes trigger re-indexing, and calling a paid embedding API for every document costs money and time.
  • For very large collections, binary quantization shrinks vectors, but it is irreversible: you would need to re-embed to undo it.
  • Compared with Postgres + pgvector: pgvector stores vectors and leaves embedding calls, keyword and vector fusion and filtering quality to your code. The upside of pgvector is one system, transactions, and exact search if you need it.

Security: keys and tenant tokens

Run Meilisearch with a master key (with --env production the server refuses to start with a master key shorter than 16 bytes). From it you get default keys: an admin key, a search key and others. The master and admin keys stay on your server.

JavaScript
const key = await client.createKey({  name: 'products search', actions: ['search'], indexes: ['products'], expiresAt: null,});

I tested that a key with actions: ['search'] on products could search that index, got invalid_api_key when writing, and also invalid_api_key on another index.

A search key in the browser can read the whole index. For multi-tenant data you need tenant tokens: a JWT that your backend signs with a search key, carrying rules such as { products: { filter: 'tenantId = "t2"' } }. Meilisearch adds that filter to every search.

JavaScript
import { generateTenantToken } from 'meilisearch/token';const token = await generateTenantToken({  apiKey: process.env.MEILI_SEARCH_KEY!,  apiKeyUid: process.env.MEILI_SEARCH_KEY_UID!,  searchRules: { products: { filter: `tenantId = "${tenantId}"` } },  expiresAt: new Date(Date.now() + 60 * 60 * 1000),});

What I verified: a token for t2 saw only t2 documents; asking for filter: 'tenantId = t1' returned nothing (your filter is added to the rule, it cannot replace it); a write with the token failed; an expired token and a token with a changed signature were both rejected (invalid_api_key). Rules for production:

  • tenantId must be in filterableAttributes.
  • Tenant tokens only restrict search. They do not protect writes or settings.
  • Always set a short expiresAt, and build the filter from the authenticated session on the server, never from request input (otherwise filter injection lets a user widen the rule).
  • Note: in the current JavaScript client, generateTenantToken is imported from meilisearch/token. Older tutorials call client.generateTenantToken(...), which no longer exists.

Keeping the index in sync with your database

The database is the source of truth. Meilisearch is a derived copy. Options:

StrategyHowTrade-off
Dual write in the requestSave to DB, then call MeilisearchSimple, but if the second call fails the two drift apart
Outbox tableIn the same DB transaction, also insert a row in an outbox table. A worker reads rows and calls Meilisearch, then marks them done.Reliable. Slight delay. You build a worker. Recommended.
CDC (change data capture)Read the database's change log (for example with Debezium) and push changesNo app changes. More infrastructure.
Periodic full re-indexNightly job loads everything into a new index and swapsHeals any drift. Good as a safety net, not as the only method

An outbox worker, run against the real server:

JavaScript
async function syncBatch(rows: Array<{ op: 'upsert'; doc: Product } | { op: 'delete'; id: string }>) {  const upserts = rows.flatMap(r => (r.op === 'upsert' ? [r.doc] : []));  const deletes = rows.flatMap(r => (r.op === 'delete' ? [r.id] : []));  const tasks = [];  if (upserts.length) tasks.push(await client.index('products').updateDocuments(upserts)); // merge, safe to repeat  if (deletes.length) tasks.push(await client.index('products').deleteDocuments(deletes));  await Promise.all(tasks.map(t => client.tasks.waitForTask(t.taskUid)));}

Make the worker idempotent (safe to run twice): upserts by primary key and deletes by id are. Send big batches rather than many small requests (the docs say one large payload is faster than several small ones; the default payload limit is 100 MB, and larger payloads use more memory). Meilisearch can also call a webhook when tasks finish, which is useful to know when an index has caught up. Keep ranking-only data (like popularity) in the index, and read the full, current record from your database after the search if freshness or permissions matter.

Frontend

The usual way is InstantSearch (a UI library of search components) with the instant-meilisearch adapter, which makes a Meilisearch-backed searchClient:

JavaScript
'use client';import { useMemo } from 'react';import { instantMeiliSearch } from '@meilisearch/instant-meilisearch';import { InstantSearch, SearchBox, Hits, RefinementList, Pagination, Highlight } from 'react-instantsearch';export function ProductSearch({ host, tenantToken }: { host: string; tenantToken: string }) {  const { searchClient } = useMemo(    () => instantMeiliSearch(host, tenantToken, { placeholderSearch: false, finitePagination: true }),    [host, tenantToken],  );  return (    <InstantSearch indexName="products" searchClient={searchClient}>      <SearchBox placeholder="Search products" />      <RefinementList attribute="category" />      <Hits hitComponent={({ hit }) => <Highlight attribute="name" hit={hit} />} />      <Pagination />    </InstantSearch>  );}

This compiles with the real types. I also called the adapter's searchClient.search directly and got the response shape InstantSearch expects (hits, facets, nbHits, page, nbPages). Practical notes: pass the tenant token or search key, never the admin key; build the client once (useMemo); finitePagination makes page counts exact. Check that the adapter version and the meilisearch client version match its declared peer range (in my install, the adapter wanted meilisearch@^0.57 while the latest client was 0.62, and npm complained until I forced it).

A common Next.js pattern is a route handler or server component that creates the tenant token for the logged-in user, and passes it to the client component.

Running it

  • Backups. Snapshots are exact copies of the database: fast to restore, same version only, can be scheduled (--schedule-snapshot). Dumps are portable: slower to import, work across versions, and are what you create before an upgrade. Both are tasks: I created one of each with POST /snapshots and POST /dumps.
  • Scaling. Memory-mapped disk storage means the index does not have to fit in RAM. Keep indexes under about 2 TiB. Don't create hundreds of indexes. Horizontal scale (sharding and replication) is Enterprise Edition.
  • Hard limits to remember: 10 query words (extra words are ignored), 65 535 positions per field, primary key 511 bytes, 1 000 concurrent searches, default 1000 results per query.
  • Don't use it as the main database. Only index what users search.
  • Testing. Start a real instance in CI (it is a single binary or Docker image) with a random index name per test, waitTask() after writes, and assert on hit ids, not on scores. Unit-test your sync mapping code without the server.

Part 2: Product analytics with Mixpanel

The data model

  • An event has a name, a time and a distinct_id (who), plus properties (details). Events are like table rows in a log: you add them, you do not edit them.
  • User profiles hold the latest facts about a user (plan, email). Events carry the facts at the time of the event. Mixing the two up is a common mistake.
  • Groups (for example a company) let you analyse by account instead of by person. Group Analytics is a paid add-on.
  • Super properties are properties registered once and added to every later event from that browser (for example app_version).

Event limits: about 5 000 distinct event names and 5 000 distinct property names per project (soft limits: more are still ingested but not indexed for autocomplete), 255 properties per event.

Identity: distinct_id, identify, reset

This is the most asked Mixpanel question. Mixpanel must know that the anonymous visitor and the logged-in user are the same person.

With the Simplified ID Merge system (the default for new organisations since April 2024), events carry two ids:

  • $device_id: made by the browser SDK, stands for the anonymous device.
  • $user_id: set when you call identify('<your user id>').

If an event has $user_id, that is its distinct_id. Otherwise the distinct_id is $device:<device id>. When an event contains both ids for the first time, Mixpanel links them into an identity cluster, and earlier anonymous events are re-attributed to the user.

I ran the real browser library in a simulated browser and captured what it sent:

TEXT
before identify : distinct_id "$device:3a1df73f-..."    (event "Viewed Pricing")identify('user_42') sends an "$identify" eventafter identify  : distinct_id "user_42", $device_id "3a1df73f", $user_id "user_42"  (event "Signed Up")after reset()   : new "$device:0227..." id; $user_id and super properties are gone

The rules:

  1. Call identify(userId) at sign-up and log-in, and when the app opens with an already logged-in user.
  2. Send at least one event after identify, because the merge happens when both ids appear on an event.
  3. Call reset() at logout, so the next person on a shared device is not merged into the previous one.
  4. Use a stable internal id (a database id) as userId, not an email. In the Simplified system you cannot merge two $user_ids or change one.
  5. Do not call identify for anonymous visitors, and avoid creating user profiles for them. Profile properties set on the device id are not carried over to the user.
  6. The device-to-user mapping shows in the user's activity feed within about a minute, but it can take up to 24 hours to apply everywhere, so a funnel that crosses login can briefly show false drop-offs.

The older Original system used $identify, $merge and alias() calls and has a limit of 500 ids per cluster. A project that already has data cannot switch systems. Many old tutorials show alias. Know that it exists, but do not use it in new projects.

For servers, there is no automatic id. Send $device_id (from a cookie) and $user_id on server events, and let Mixpanel work out the distinct_id.

Setting it up in Next.js

Next.js has a file for exactly this: instrumentation-client.ts at the project root. It runs in the browser before the app becomes interactive, so there is no server-side rendering problem.

JavaScript
// instrumentation-client.tsimport mixpanel from 'mixpanel-browser';mixpanel.init(process.env.NEXT_PUBLIC_MIXPANEL_TOKEN!, {  api_host: process.env.NEXT_PUBLIC_MIXPANEL_HOST ?? 'https://api-eu.mixpanel.com',  autocapture: false,  track_pageview: false,             // we send page views ourselves  opt_out_tracking_by_default: true, // start opted out until the user consents  ip: false,  persistence: 'localStorage',});
JavaScript
// components/page-views.tsx  (render it once in the root layout)'use client';import mixpanel from 'mixpanel-browser';import { usePathname } from 'next/navigation';import { useEffect } from 'react';export function PageViews() {  const pathname = usePathname();  useEffect(() => { mixpanel.track_pageview({ page: pathname }); }, [pathname]);  return null;}

Why it looks like this:

  • Project token is public. It only identifies the project for writing events, so NEXT_PUBLIC_ is fine. Secrets (service account, API secret) never go in the browser.
  • SSR safety. The browser library must run in the browser. I imported it in plain Node: init and track did not throw, but nothing was sent (zero requests reached my capture server). So calling it from a Server Component or at module level on the server fails silently. Keep calls inside Client Components, effects, event handlers, or instrumentation-client.ts.
  • Page views in a single-page app. Next.js changes the URL without a page load. The library's track_pageview: 'url-with-path' option watches the History API. I verified it: moving through /, /pricing, /pricing?tab=2 and /docs produced page views for /, /pricing and /docs (the query-only change was ignored). 'url-with-path-and-query-string' and 'full-url' count more kinds of change. The manual usePathname effect above gives you full control instead. Use one method, not both, or you will double-count.
  • Defaults to know (read in the library source): api_host defaults to https://api-js.mixpanel.com; batch_requests: true; batch_size: 50; batch_flush_interval_ms: 5000; persistence: 'cookie'; cookie_expiration: 365 days; autocapture: false; track_pageview: false; ip: true; opt_out_tracking_by_default: false.
  • Batching. By default events go into a queue in localStorage and are sent together. In my run there were 0 requests after 1 second and one request after the 5-second flush, carrying all four events. Two consequences: events can be lost if the tab dies before a flush (the library tries to flush when the page hides), and the arrival order inside a batch is not reliable, so rely on the event time, not order.
  • Autocapture records clicks, form submits, scrolls and more without code, but it is noisy and can capture more than you intended. It does not capture input text, and text of elements only if capture_text_content is true. Use .mp-no-track or .mp-sensitive on private elements. Prefer explicit, named business events for the metrics you care about.

Server-side tracking

Track events that must not be lost or faked, like Order Completed, from the server with the Node library:

JavaScript
import Mixpanel from 'mixpanel';const mp = Mixpanel.init(process.env.MIXPANEL_TOKEN!, { host: 'api-eu.mixpanel.com', protocol: 'https' });export function trackServer(event: string, userId: string, deviceId: string | undefined, props: { orderId: string } & Record<string, unknown>) {  return new Promise<void>((resolve, reject) => {    mp.track(event, { $user_id: userId, $device_id: deviceId, $insert_id: `${event}:${props.orderId}`, ...props },      err => (err ? reject(err) : resolve()));  });}

Points (the first two I confirmed by running it against a local server):

  • The call is callback-based. In serverless functions the process can freeze right after your response, so wrap it in a promise and await it, or the event may never be sent.
  • It sends ip=0 by default, so server events do not get your data centre's location. If you want the user's location, pass the user's IP yourself.
  • track_batch sends many events at once (the library splits them into requests of up to 50).
  • $insert_id makes a retry idempotent: Mixpanel de-duplicates events with the same insert id, so a retried webhook does not count twice.
  • track accepts only events from the last 5 days. For older data use import() (the Import API).
  • The server library has no $browser, UTM or referrer data. Pass them yourself if you need them.

Reading the data: reports

  • Insights: count or aggregate events over time, broken down by properties.
  • Funnels: ordered steps with a conversion window (the time a user has to finish; the default is 7 days). A user who changes distinct_id between steps looks like a drop-off, which is why identity matters.
  • Retention: of the users who did event A, how many did event B on later days or weeks.
  • Flows: the most common paths before or after an event.
  • Cohorts: saved groups of users (for example "paid in the last 30 days") that you can reuse in reports or sync to other tools.

Naming and governance

Events are cheap to send and expensive to clean up. Mixpanel strings are case-sensitive: sign_up_completed and Sign_Up_Completed are two different events. Good habits (from Mixpanel's own guidance):

  • Write a tracking plan: a shared document or table that lists each event, its properties, when it fires, and who owns it. Start from the questions (KPIs) you must answer, not from "track everything".
  • Use one naming style (for example snake_case, object then verb: order_completed).
  • Keep events as actions and put the details in properties: one Add to Cart event with an Item property, not Add Shirt to Cart, Add Hoodie to Cart... Never put dynamic values in the event name (Purchase (11-01-2019)).
  • In Mixpanel, Lexicon is the data dictionary (descriptions, hiding events), and there are data standards and event approval tools. In code, a typed wrapper makes drift impossible:
JavaScript
type Events = {  order_completed: { order_id: string; total_cents: number; currency: 'USD' | 'EUR' };  sign_up_completed: { method: 'email' | 'google' };};export function track<E extends keyof Events>(name: E, props: Events[E]) {  mixpanel.track(name, props);}
  • Beware high-cardinality values (a property with millions of distinct values, like a raw URL with ids or a timestamp string). They make breakdowns useless. Send a normalised value instead (/orders/:id).
  • Hot shards: one distinct_id that gets more than 200 000 events in a day is rewritten to a $hotshard_events event with an empty id. The usual cause is a fixed id such as "0" or a server name used for events that do not belong to a user. If an event has no user, send an empty distinct_id.
  • Use a separate project for development and testing so test data does not pollute production, and set $ignore: true to drop bot or internal traffic (the library already filters a short list of known search-engine bots).
  • Consent. Start opted out (opt_out_tracking_by_default: true), call opt_in_tracking() after consent and opt_out_tracking() if consent is withdrawn. I verified that after opt_out_tracking(), track sent nothing and has_opted_out_tracking() was true.
  • Collect less PII. Do not use an email as the id. Do not put emails or free text in event properties. Set ip: false if you do not need location. You can drop default properties with property_blacklist.
  • Data residency. By default data is stored in the US. For EU residency, create the project as EU and send data to api-eu.mixpanel.com (set api_host in the browser library, host in the Node library). Data sent to the wrong region is not ingested. There is also an India region. If you use a CDP such as Segment, the CDP must also send to the EU endpoint.
  • Deletion. Mixpanel offers APIs to delete user profiles and to handle data-subject requests. Event data is automatically deleted after 2 years for projects created from 1 September 2025 (older projects have 5 years until their plan changes). Check the current policy when you design retention.
  • Ad blockers block requests to known analytics domains, so some events never arrive. Options: a first-party domain (a subdomain you own, such as track.example.com, pointing at Mixpanel; this is an Enterprise feature), or your own reverse proxy that forwards to Mixpanel (any plan, but you run it). Send the events that matter most (purchases) from the server so blockers cannot hide them. A proxy does not remove the duty to get consent.

Choosing a tool (general knowledge, check current details)

Best known forRemember
MixpanelEvent-based product analytics: funnels, retention, flows, cohortsNeeds a good tracking plan. Group analytics is paid.
AmplitudeVery similar event analytics focusSimilar identity and planning needs
PostHogProduct analytics plus feature flags, session replay and experiments in one product; can be self-hostedWider scope
GA4Marketing and website traffic, free, ad-platform linksLess suited to deep product-behaviour questions
SegmentA data pipeline (a "CDP"): collect once, send to many toolsIt does not analyse; it routes data

The honest interview answer: tools overlap, the hard part is the same everywhere (clean events and identity), and warehouse-based setups are an alternative when you want full control.

Common mistakes

  • Search: filtering on a field that is not in filterableAttributes; trusting totalPages past maxTotalHits; using addDocuments when you meant to merge; ignoring failed tasks; exposing the admin key; filtering tenants on the client.
  • Analytics: calling identify too late or never calling reset; using email as the user id; naming drift (Signup vs sign_up); tracking everything with no plan; calling the browser SDK on the server and losing events silently; forgetting the EU host.

Interview questions

When would you add a search engine instead of using database queries?

When users type free text and expect typo tolerance, ranking, facets and results in tens of milliseconds, and when database LIKE or full-text search is too slow or too plain. Start with the database if the data is small and the search is simple, because a search engine is a second copy of the data that I must keep in sync. If I need search-as-you-type over a large catalogue, I add Meilisearch (or Elasticsearch at larger scale) and keep the database as the source of truth.

Meilisearch versus Elasticsearch, Typesense, Algolia and Postgres full-text search?

Meilisearch is easy to start, has typo tolerance, filters, facets and hybrid search by default, runs as one binary and stores data on disk. Elasticsearch scales much further and does analytics, but is heavier to run and tune. Typesense is similar but keeps the index in RAM. Algolia is a mature hosted service with merchandising tools, but is closed source and priced by usage. Postgres full-text search needs no new system and is always consistent, which is great for simple needs, but it is weaker at typos, facets and search-as-you-type. I would also mention that vendor comparison pages are biased, and I choose by data size, team skills and operations cost.

Explain index, document and primary key. Why do writes return a task?

An index is a named set of documents with its own settings. A document is a JSON object, and the primary key uniquely names it. Indexing is slow work, so Meilisearch accepts the request, returns a task id with status enqueued, and processes it in the background. I wait for the task or poll it, and I check the final status because failures (like a missing primary key) appear in the task, not in the HTTP response. The result is that search is eventually consistent with my writes.

What are searchableAttributes, filterableAttributes and sortableAttributes, and what is the trap with them?

Searchable attributes are the fields the query text matches, and their order sets importance. Filterable attributes are the fields allowed in filter and facets. Sortable attributes are the fields allowed in sort. By default everything is searchable and nothing is filterable or sortable, so a first filter fails with invalid_search_filter. The trap is cost: changing these settings re-indexes the whole index, so I set them before loading a large dataset and keep the lists short.

How does Meilisearch rank results, and how would you tune relevance?

It applies ranking rules in order as tie-breakers: words, typo, proximity, attribute rank, sort, word position, exactness. To tune: order searchableAttributes by importance, add synonyms and stop words, add a custom rule such as popularity:desc at the end as a business tie-breaker, and move sort up only if strict sorting matters more than relevance. I debug with showRankingScore. I change one thing at a time and keep a small list of test queries with the results I expect.

How does typo tolerance work, and when would you turn it off?

By default one typo is allowed for words of 5 or more letters and two typos from 9 letters. Short words get none: mose found nothing but mouze found "Mouse" in my test. The last word is treated as a prefix, which gives search-as-you-type. With the default matching strategy last, unmatched words at the end are dropped, so a query with a bad last word still returns results. I turn typo tolerance off for ids, SKUs and exact codes (per attribute), and I use matchingStrategy: 'all' when results must contain every word.

How do you build a faceted product page with filters, counts and sorting?

I put the facet fields in filterableAttributes and sort fields in sortableAttributes. Each search sends q, a filter built from the selected values, sort, and facets for the fields I want counted. The response has facetDistribution for the counts. Inside one facet group I combine selections with OR, across groups with AND. If I want counts for the other values of the same group while one is selected, I run an extra query for that group (multi-search makes it one HTTP call). On the front end, InstantSearch's RefinementList does most of this.

A page shows 150 pages of results but pages after 50 are empty. Why?

Meilisearch returns at most maxTotalHits results per query, default 1000, so with 20 per page only 50 pages exist. The count can still say 3000. I saw exactly this in a test. Options: raise maxTotalHits (slower queries, and the docs warn about large values), but usually the better fix is a UX one: show Previous/Next, cap the pages, and encourage filtering. To walk all documents for an export or a sync check, I use the get-documents endpoint, which is not capped.

How do you keep Meilisearch in sync with your database?

The database is the source of truth. I prefer the outbox pattern: in the same transaction that changes a product, I insert a row in an outbox table. A worker reads batches, calls updateDocuments for changes and deleteDocuments for removals, waits for the tasks, and marks rows done. It is reliable because the change and the outbox row commit together. The worker is idempotent, since upsert by key and delete by id are safe to repeat. Alternatives are CDC (reading the database log, no app changes but more infrastructure) and dual writes (simple but they drift if the second call fails). I add a nightly full re-index as a safety net. I also expect a small delay, so the UI must tolerate a document that is a few seconds stale.

How do you secure search for a multi-tenant SaaS?

The admin and master keys stay on the server. The browser gets a tenant token: a JWT my backend signs with a search key, containing a filter like tenantId = "<id>" taken from the authenticated session, plus a short expiry. Meilisearch adds that filter to every search. I tested that the token only returned its tenant's documents, that asking for another tenant returned nothing, that writes failed, and that expired or altered tokens were rejected. Requirements: tenantId must be filterable, I never build the rule from request input, and tokens protect search only, not writes. If the data is very sensitive, I can also use one index per tenant (at the cost of many indexes) or search through my own backend.

Explain hybrid search. When would you use it?

Hybrid search mixes keyword matching with semantic (embedding) matching in one query. I configure an embedder in the index settings and send hybrid: { embedder, semanticRatio }. The ratio runs from 0 (keywords only) to 1 (semantic only). I use it when users describe things in their own words ("something to keep drinks cold") and plain keywords miss them, while still keeping exact matches like product codes. Costs: embedding every document (time and money with a paid provider), re-indexing when the embedder changes, and tuning the ratio on real queries. Compared with pgvector, the engine handles embedding calls, fusion and filtering for me, while pgvector keeps everything in one database with transactions.

How do you run and scale Meilisearch in production?

One instance with a master key, --env production, HTTPS in front, and disks with fast SSDs and no I/O limits. I schedule snapshots for backup and take a dump before any version upgrade, because snapshots only restore on the same version while dumps are portable. I watch the task queue and batch durations. Indexing uses CPU and memory heavily, so I send big batches off-peak and avoid re-indexing settings changes during busy hours. Scaling out horizontally (sharding and replication) is an Enterprise feature, so for the Community Edition I scale up first and keep indexes under about 2 TiB.

Gotcha: your search page gets slow and your indexing takes hours after a "small" settings change. What happened?

Changing searchable, filterable or sortable attributes (and some other settings such as typo tolerance, synonyms or embedders) forces a re-index of all documents. On a big index that takes a long time and uses a lot of CPU. I would check the task queue to confirm, wait for it, and in future make settings changes before the bulk load, in a quiet window, or build a new index and swap to it. I would also trim unneeded searchable fields to reduce the work.

In Mixpanel, what are distinctid, $deviceid and $userid, and how do you connect anonymous and logged-in activity?

distinct_id is the id Mixpanel uses to decide which user an event belongs to. The browser library makes a $device_id for anonymous visitors, so their distinct_id is $device:<id>. When I call identify(userId), later events carry both $device_id and $user_id. The first event with both ids links them into an identity cluster, and earlier anonymous events are re-attributed. So: identify at login and sign-up, send an event after it, and reset at logout. I use a stable database id, not an email. I verified in the real library that the id changes from $device:... to the user id after identify and goes back to a new $device:... after reset.

What goes wrong if you forget reset() on logout?

On a shared computer the next person's anonymous events get the previous user's ids, so two people are merged into one profile. That corrupts funnels, retention and any per-user numbers, and it is very hard to repair, because events are immutable. reset clears the stored ids and super properties and creates a new $device_id. I also test the flow "user A logs out, user B logs in on the same browser" in QA.

How would you set up Mixpanel in a Next.js App Router app?

Install mixpanel-browser. Initialise it in instrumentation-client.ts, which runs only in the browser. Use the public project token in a NEXT_PUBLIC_ variable, the right regional api_host (EU if the project is EU), and opt out by default until consent. Send page views from a small Client Component that uses usePathname in an effect, or use track_pageview: 'url-with-path', but not both. Call identify after login and reset at logout. Calls from server code do nothing: in a plain Node test the library did not throw and did not send anything. For business-critical events I track from the server with the mixpanel Node library and $insert_id.

Client-side or server-side tracking?

Both, for different events. Client-side is easy and gives free context (browser, UTM, referrer, device id) and UI events like clicks. But it can be blocked by ad blockers, lost when a tab closes before the batch flushes (default flush every 5 seconds, up to 50 events), and it can be faked by a user. Server-side is reliable and trusted, ideal for purchases, sign-ups and subscription changes, but it lacks browser context and needs me to pass ids myself ($device_id from a cookie and $user_id). On serverless I must await the callback or the event may be lost. I add $insert_id so retries do not double-count.

Ad blockers drop 15% of your events. What do you do?

First, measure it (compare server-side counts to client-side counts for the same action). Then: move the key events to the server; use a first-party domain (a subdomain I own that points at Mixpanel, an Enterprise feature) or my own reverse proxy so requests go to my domain; and accept that some loss on non-critical UI events is normal. I remind the team that a proxy does not remove the need for consent, and that blocked requests mean fewer events, not wrong identities, so I do not "fix" numbers by guessing.

How do you handle privacy and GDPR with Mixpanel?

Collect the minimum. Start opted out and call opt_in_tracking() only after consent, and honour withdrawal with opt_out_tracking(). Don't send emails or free text as properties, don't use email as the id, and set ip: false if I don't need location. For EU users use an EU-residency project and send to api-eu.mixpanel.com in both the browser and server libraries, because data sent to the wrong region is not ingested. Have a process for deletion requests using Mixpanel's deletion and data-subject APIs, and know the retention period (events are deleted after 2 years for newer projects). Remember that a CDP in the middle must also use the EU endpoint.

How do you design a tracking plan and keep event names from drifting?

I start from the questions the business must answer (for example "what percent of visitors sign up, and where do they drop?"), map each to a user flow, and then list the events and properties needed, with the trigger, the owner and an example. I keep events as actions (order_completed) and put detail in properties, use one naming style, never put dynamic values in names, and write the plan in a shared document that changes with each release. In code I use a typed track() wrapper so a wrong name or property is a compile error. In Mixpanel I use Lexicon to describe and hide events, and a separate development project for testing. Names are case-sensitive, so Sign_Up and sign_up are two events.

Key takeaways

  • Meilisearch: each write is an asynchronous task, so check the task and expect eventual consistency. Always set primaryKey.
  • Set searchableAttributes (order = importance), filterableAttributes and sortableAttributes before loading data. Changing them re-indexes everything.
  • Results are capped by maxTotalHits (default 1000), even when the total count is higher.
  • Use tenant tokens (JWT with a filter, short expiry, built from the session) for multi-tenant search. Admin keys never leave the server.
  • Sync with an outbox + idempotent batch worker, and add a periodic full re-index.
  • Mixpanel identity: identify at login, an event after it, reset at logout, a stable user id. Simplified ID Merge is the default for new organisations.
  • Browser SDK calls on the server are silent no-ops. Use instrumentation-client.ts in Next.js, and track critical events from the server with $insert_id.
  • Plan the events (names, properties), keep events as actions, start opted out, use the EU host for EU data, and expect ad blockers.

What's next

Backend, Lesson 03: Validation at the Edge is a good follow-up: tenant ids, search filters and event properties are all input you must validate before you trust them.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series