Passing Filter State into an Embedded Map with postMessage

Part of the Iframe Embedding & Isolation guide.

Operative rule: send intent, not implementation — a message says “show categories A and B between these dates”, never a renderer expression, or the host page becomes coupled to whichever library the map happens to use this quarter.

The Contract Is the Design

Once controls live outside the map, the message format is the interface between two codebases that will be changed by different people at different times. Treating it as a versioned contract — a named channel, a version field, a small set of message types and a documented payload shape — is what keeps that interface from decaying into a set of undocumented assumptions.

Sending intent rather than implementation is the other half. A payload of field names and values can be translated by the frame into whatever its current renderer needs; a payload containing a renderer expression means every change to the map’s internals is a breaking change to the host.

Intent versus implementation in the message payload A payload containing a renderer filter expression couples the host to the map library and breaks when the renderer changes. A payload containing field names, operators and values is translated inside the frame, so the map can be rebuilt on any renderer without touching the host. The payload decides how brittle the integration is implementation — the host sends a renderer expression ["all", ["in", ["get", "category"], …], [">=", ["get", "ts"], 1750000000]] changing renderer, field name or tile schema is now a breaking change for the host intent — the host sends what the reader asked for { categories: ["flood", "storm"], from: "2026-01-01", to: "2026-06-30" } the frame translates it into whatever its renderer needs, today and later the host stays a control surface and never learns what a layer is

Production-Ready Implementation

// ── host page ─────────────────────────────────────────────────────────────
const CHANNEL = "geo-dashboard:v1";
const FRAME_ORIGIN = "https://maps.example.org";
const frame = document.getElementById("map-frame");

let frameReady = false;
const queued = [];

function send(type, payload) {
  const message = { channel: CHANNEL, type, payload };
  if (!frameReady) { queued.push(message); return; }   // never lost
  frame.contentWindow.postMessage(message, FRAME_ORIGIN);
}

window.addEventListener("message", (event) => {
  if (event.origin !== FRAME_ORIGIN) return;
  if (event.source !== frame.contentWindow) return;
  if (event.data?.channel !== CHANNEL) return;
  if (event.data.type === "ready") {
    frameReady = true;
    while (queued.length) frame.contentWindow.postMessage(queued.shift(), FRAME_ORIGIN);
  }
});

// Controls call this; it is the only coupling point.
export function applyFilters({ categories, from, to, minSeverity }) {
  send("filter", { categories, from, to, minSeverity });
}

// ── embedded map ──────────────────────────────────────────────────────────
const HOST_ORIGIN = "https://dashboard.example.org";
const ALLOWED_CATEGORIES = new Set(["flood", "storm", "heat", "fire"]);

function validateFilter(payload) {
  const categories = Array.isArray(payload?.categories)
    ? payload.categories.filter((c) => ALLOWED_CATEGORIES.has(c))
    : [];
  const asTime = (value) => {
    const t = Date.parse(value ?? "");
    return Number.isFinite(t) ? t / 1000 : null;
  };
  return {
    categories,
    from: asTime(payload?.from),
    to: asTime(payload?.to),
    minSeverity: Number.isFinite(payload?.minSeverity)
      ? Math.min(Math.max(payload.minSeverity, 0), 5) : 0,
  };
}

function toExpression(filter) {
  const expression = ["all"];
  // An empty category list means "no constraint", never "match nothing".
  if (filter.categories.length) {
    expression.push(["in", ["get", "category"], ["literal", filter.categories]]);
  }
  if (filter.from !== null) expression.push([">=", ["get", "ts"], filter.from]);
  if (filter.to !== null) expression.push(["<=", ["get", "ts"], filter.to]);
  if (filter.minSeverity > 0) {
    expression.push([">=", ["get", "severity"], filter.minSeverity]);
  }
  return expression;
}

window.addEventListener("message", (event) => {
  if (event.origin !== HOST_ORIGIN) return;
  const data = event.data;
  if (data?.channel !== CHANNEL || data.type !== "filter") return;
  const filter = validateFilter(data.payload);
  map.setFilter("incidents", toExpression(filter));
});

parent.postMessage({ channel: CHANNEL, type: "ready" }, HOST_ORIGIN);

Validation on arrival is not defensive decoration. The payload crossed an origin boundary, so it is input: category names are checked against an allow-list, dates are parsed rather than trusted, and numbers are clamped. A message that fails validation should degrade to a safe filter — usually “show everything” — rather than throwing and leaving the map in whatever state it was in.

Handling the Ready Race

Queue before ready, flush after The host may apply a filter from its own URL before the frame's listener exists. Those messages are queued rather than sent. When the frame announces readiness the queue is flushed in order, so no state is lost and the map arrives already filtered. The host is usually ready first — that is the whole problem host applies URL filters before the frame has parsed queued, not sent order preserved frame posts "ready" → flush map arrives already filtered Why the frame's load event is not enough the document can be loaded while the map, its style and its listener are still initialising the race only appears on slow connections, which is precisely where readers notice it a queue costs four lines and removes the failure entirely

Filtering Without Refetching

Three ways to apply a filter, by cost A renderer filter expression re-evaluates on the GPU and costs nothing beyond a frame. A feature-state flag suits dimming rather than hiding and keeps context visible. Refetching a filtered dataset is the most expensive and is only necessary when the data is too large to ship unfiltered. Prefer filtering what is already loaded setFilter with an expression one frame, no network, no re-upload the default choice whenever the data is already in the tiles feature-state flag + a dimming expression keeps context visible better than hiding when the reader needs to see what was excluded refetch a filtered dataset a round trip per interaction — last resort necessary only when the unfiltered data is too large to ship at all

Verification Steps

  • Apply a filter from the host before the frame finishes loading and confirm it takes effect.
  • Send a payload containing an unknown category and confirm it is dropped, not applied.
  • Send a malformed date and confirm the map shows everything rather than nothing.
  • Post a message from an unexpected origin and confirm the frame ignores it.
  • Confirm applying a filter causes no network requests when the data is already loaded.

Common Errors & Fixes

The filter applies on the second interaction but not the first

The ready handshake is missing. Queue on the host and flush on ready.

An empty filter hides everything

An empty category list is being translated into an in clause with no values, which matches nothing. Treat empty as “no constraint” and omit the clause.

Filters work until the map is rebuilt with a different renderer

The message carried an expression. Move the translation inside the frame so the contract stays about intent.

The host and the frame disagree about what is filtered

Two sources of truth. The host owns the control values; the frame owns the rendering. Have the frame post its applied filter back and let the host reconcile if they differ.

Acknowledging What Was Applied

A one-way channel leaves the host guessing. It sent a filter; it does not know whether the frame received it, whether every clause was understood, or how many features remain. For a control panel that is enough to produce a confusing interface — a category checkbox that appears active while the map shows everything, because the category name was rejected by validation.

An acknowledgement message closes the loop cheaply. After applying a filter the frame posts back what it actually applied and what the result was: the normalised filter, the number of features matching it in the current view, and a list of anything it ignored. The host can then reflect reality — greying out a category that produced no matches, showing the result count beside the controls, or warning that a value was not recognised.

The count is the part readers notice. “142 features match” beside a filter panel turns an abstract control into a direct answer, and it makes an empty result explicable rather than alarming: a map that goes blank after a filter is a bug until a number explains that the filter matched nothing.

Keep the acknowledgement advisory. The host should never block on it, because a frame that fails to respond must not freeze the surrounding page, and a timeout that silently disables the controls is worse than a control panel that is occasionally optimistic.

Where Filter State Should Live

Once filters are driven from the host, the question of who owns them has a clear answer: the host, because that is where the controls are and where the URL is. The frame holds a derived copy so it can render, and treats every incoming message as authoritative.

That ordering matters when a reader shares a link. The host serialises its own filter state into its URL, the recipient’s host page parses it, and it is posted into the frame during the ready handshake — the same path a fresh interaction takes. There is no separate restore mechanism inside the frame, and therefore no second implementation to keep in step.

It also settles what happens when the frame reloads for any reason: it comes up filterless, announces readiness, and the host immediately re-sends the current state. The reader sees the map briefly unfiltered and then correct, which is acceptable; what they must never see is a map that stays unfiltered while the controls insist otherwise.

Gotchas & Edge Cases

  • Filtering a layer does not filter its labels unless the label layer carries the same filter; a filtered map with unfiltered labels is a common and confusing half-state.
  • A filter applied to a vector-tile layer can only reference attributes retained during tiling, so the host’s control vocabulary is constrained by a decision made in the pipeline.
  • Date filters need a timezone contract. Send absolute instants rather than local dates, or a filter set in one timezone selects a different range for a reader in another.
  • Clearing a filter must be an explicit message rather than an empty payload, so a malformed message cannot be mistaken for “show everything”.
  • If the map aggregates or clusters, a filter changes the aggregates too — the reader should see the bins recomputed, not the same bins with fewer members hidden inside them.