How 1,000 Mapped Fields in Elasticsearch Made New Listings Invisible for 11 Days
← Back
November 3, 2026ElasticSearch10 min read

How 1,000 Mapped Fields in Elasticsearch Made New Listings Invisible for 11 Days

Published November 3, 202610 min read

03:12 UTC. One support ticket: "my listing isn't showing up in search." Not urgent-looking, not a spike, just one seller who'd listed a guitar amp an hour earlier and couldn't find it. By the time the support queue opened at 09:00, there were six more, all different sellers, all different categories, all the same complaint. Nothing in Grafana was red.


the scramble

First theory: relevance, not absence. New listings sometimes rank low until they accumulate views and clicks, so "can't find it" often just means "page three." Someone pulls up one of the reported listing IDs and searches its exact title, word for word. Zero results. Not buried, gone.

Second theory: the Kafka consumer that feeds the search indexer has fallen behind. The listings service writes to Postgres and emits a listing.created event; a consumer picks it up and upserts the document into Elasticsearch. If that consumer were lagging, new listings would just take longer to appear. Consumer group lag:

kafka-consumer-groups.sh --describe --group search-indexer
TOPIC            PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
listing-events   0          8842210         8842213         3
listing-events   1          8841998         8842001         3
listing-events   2          8842055         8842055         0

Lag in single digits. The consumer is keeping up in real time. Whatever is eating these listings, it isn't a backlog, and it isn't happening somewhere between Kafka and the indexer process. It's happening inside the upsert itself, and the dashboards that would normally show an indexing problem, consumer lag, CPU, cluster health, were all green because none of them measure "did the write actually succeed."


the hunt

Cluster health was green because nothing about the cluster was unhealthy. The problem had to be in the indexer's own application logs, not the infra dashboards, so the next step was grepping the indexer's log stream directly for the IDs from the support tickets:

search-indexer application logs, 03:09:51 UTC
WARN  index_upsert_failed listing_id=lst_8f2k1 index=listings
  illegal_argument_exception: Limit of total fields [1000] in index [listings]
  has been exceeded

Every missing listing had this exact line, same error, same index, logged at WARN. Nobody had an alert on indexer warn-level logs, because the indexer's retry handler had been written specifically to survive one bad document without blocking every listing queued behind it:

search-indexer.js, before
async function upsertListing(doc) {
  try {
    await es.index({ index: 'listings', id: doc.id, document: doc });
  } catch (err) {
    log.warn('index_upsert_failed', { listing_id: doc.id, error: err.message });
    // deliberately swallow: one bad document shouldn't stall the
    // consumer for every listing behind it in the partition
  }
}

That design made sense for its original purpose, which was surviving the occasional malformed payload from a buggy client. It had no way to distinguish "this one document is broken" from "every future document with a new attribute will fail the exact same way, forever, until the mapping is fixed." The first is transient. The second isn't, and it had been happening to a growing share of new listings since a feature shipped three weeks earlier: Custom Specs, which let sellers attach arbitrary key-value attributes to a listing (battery_life_hours, oak_finish, frame_size_cm), free text on both sides, indexed dynamically so sellers' custom fields would be searchable without anyone pre-defining a schema for them.

Elasticsearch's dynamic mapping creates a new mapped field the first time it sees a key it hasn't seen before, and it does this cumulatively across every document ever indexed into that index, not per document. A listing with three custom spec keys doesn't cost three fields of headroom and give them back when the listing is deleted; it permanently claims three slots against the index's total field budget, shared by every seller who will ever list anything. The default budget, index.mapping.total_fields.limit, is 1,000:

GET /listings/_mapping?filter_path=listings.mappings.properties
# field count via _field_caps, sampled daily for the three weeks after launch
Oct 13: 412 fields
Oct 20: 781 fields
Oct 27: 1,000 fields  <- cap reached, first silent rejection
Nov 2:  1,000 fields  <- still capped, 11 days of rejections by now

Once every sellers' worth of unique attribute names pushed the index past 1,000 distinct fields, any listing introducing even one more new key failed outright. Listings reusing only already-seen keys kept indexing fine, which is exactly why this looked random from the support queue: some sellers hit it immediately, others didn't hit it for days, depending entirely on whether their particular combination of spec names happened to be new to the index.


the find

Custom Specs mapped seller-defined attributes as regular dynamic object fields. Elasticsearch mapped a new field for every unique key ever seen, cumulatively, and the index crossed its 1,000-field cap eleven days before anyone noticed. Every indexing call that would have added a 1,001st field failed with illegal_argument_exception, and the indexer's own error handling, built to protect throughput against one-off bad documents, logged that failure at a level nobody was watching and moved on to the next message.


the fix

The real fix was changing how seller-defined attributes get mapped at all. Elasticsearch has a type built for exactly this shape of data, arbitrary key-value pairs that shouldn't each become their own mapped field: flattened. A flattened field is indexed and searchable as a single field no matter how many distinct keys live inside it.

listings mapping, after
PUT /listings-v2/_mapping
{
  "properties": {
    "custom_specs": { "type": "flattened" }
  }
}

That required a reindex into a new index and an alias swap rather than an in-place mapping edit, since Elasticsearch doesn't let you change an existing field's type. Second, the swallowed exception became a counted, alertable failure instead of a silent one:

search-indexer.js, after
async function upsertListing(doc) {
  try {
    await es.index({ index: 'listings', id: doc.id, document: doc });
  } catch (err) {
    log.error('index_upsert_failed', { listing_id: doc.id, error: err.message });
    indexUpsertFailures.inc({ error_type: err.name });
    // still don't block the consumer on one bad document, but now
    // a sustained failure rate pages someone instead of hiding in WARN
  }
}

A Prometheus alert on indexUpsertFailures sustaining above a low threshold for more than five minutes now pages the on-call team directly, which is the gap that let this run for eleven days in the first place: the failure rate was never zero, so a threshold alert would have caught it on day one instead of waiting for a support ticket.


the aftermath

1,340 listings silently missing from search over the 11-day window
41 min from the first grep of indexer logs to confirming the mapping cap as root cause

A backfill script re-ran every affected listing ID against the new flattened mapping, and all 1,340 were searchable again within the hour. No listing had been lost; Postgres had every one of them the whole time. They were simply invisible to the one surface buyers actually use to find anything.

  • Dynamic mapping on freeform object fields is a shared, cumulative resource across an entire index, not a per-document cost. A feature that lets users name their own fields needs either a bounded vocabulary or a type like flattened that doesn't consume the field budget at all.
  • A circuit breaker that protects throughput by swallowing exceptions needs its own compensating alert, or it quietly converts every permanent failure into an invisible one. The original design wasn't wrong to avoid blocking the consumer; it was incomplete without a counter watching how often it fired.
  • "Nothing is red on the dashboard" only means the metrics being watched are the right ones. Consumer lag and cluster health measure whether the pipeline is moving, not whether what moves through it actually lands.

Eleven days of a growing rejection rate, and the first signal anyone outside the indexer's own log stream saw was a seller asking why their guitar amp wasn't showing up.

Share this
← All Posts10 min read