Zadig&Voltaire sells through nine storefronts, and they do not move at the same pace. A new feature might open in France on a Tuesday, in Germany the week after, and never in the United States because logistics work differently there.

For a long time, “opening a market” meant an environment variable, a chart change, a build and a deployment. For what is really a merchandising decision, that is absurd. The person who knows when to open is not the person who knows how to deploy.

So we set a rule: a shopper-facing feature is done when merchandising can open it, store by store, from the Magento back office, without a deployment. Here is how it works, and what it took to make it reliable.

The switch lives in Magento, and nowhere else

Our architecture rule is simple: business rules live in Magento, storefronts render. A feature switch is no exception. It is a plain Magento configuration field, declared in the system.xml of the module that owns the feature, with a store-view scope:

<group id="delivery_estimate" showInDefault="1" showInWebsite="1" showInStore="1">
    <label>Delivery Estimate</label>
    <field id="enabled" type="select" showInDefault="1" showInWebsite="1" showInStore="1" canRestore="1">
        <label>Enabled</label>
        <source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
    </field>
</group>

Three shapes cover every case: a boolean, a mode selector (for instance algolia, meilisearch or ab for our search engine), or a list of values where an empty list means “off”.

The default value, in config.xml, is the behaviour from before the feature. An environment nobody configured behaves exactly as it did yesterday. Exceptions are declared right next to it: the delivery date estimate is on by default and off for the US store, in the same file.

The storefront reads that field and nothing else. No parallel NUXT_PUBLIC_* variable: two sources of truth always end up contradicting each other, and the shopper is the one who sees the result. This year we actually removed two environment variables that duplicated a Magento setting, one for the search engine test and one for shipping offers on the product page.

Two channels for one field

Between a box ticked in the back office and a block appearing on a product page, the value travels down two independent roads.

Two channels for one switch: at build time, CI queries each store's GraphQL storeConfig and bakes the values into the image; live, a back-office save triggers an observer that pushes the configuration into a shared Redis, which browsers re-read at most once a minute Channel 1 · baked at build Channel 2 · pushed live Back-office scope: store view Polaris build storeConfig Docker image baked config SSR HTML cached by the CDN Observer authenticated POST Shared Redis every pod Browser re-read ≤ once a minute initial value at hydration also read by server routes ETag: 304 if unchanged
The same Magento field travels two roads. The build bakes an initial value; the push updates it on a page that is already open.

Both have to be wired. One without the other produces bugs that never raise an error.

Channel 1: the value baked at build time

On every storefront build, a script queries Magento’s GraphQL storeConfig for each of the nine stores and writes the result into the configuration shipped inside the image. That is the value server-side rendering uses, and the one every page starts with.

On the Magento side, the field must therefore be exposed on the StoreConfig type of the GraphQL schema. On the storefront side, it must appear in the script’s query. The trap is a nasty one: a field missing from the query is never requested, no error surfaces, and the feature is dead on all nine stores.

The other difficulty comes from deployment order. Magento ships to production before the storefront, but nothing guarantees that the environment queried at build time already knows the new field. A GraphQL query asking for an unknown field fails as a whole. Hence a list of so-called optional fields, stripped from the query when Magento rejects them:

const OPTIONAL_STORE_CONFIG_FIELDS = ['is_size_finder_enabled', 'search_mode', 'is_pdp_shipping_offers_enabled', /* … */]

if (errors) {
  const fallback = stripUnknownFields(query, errors)
  if (fallback) {
    console.warn(`Unknown optional field on store ${storeCode}, retrying without it.`)
    return fetchMagentoGraphQL<T>(storeCode, fallback, variables)
  }
}

The build goes through, the field is missing, and the storefront applies its default, false. On an environment whose Magento predates the feature, the feature simply stays off.

Channel 2: the live push

The first channel is not enough: waiting for a build before a ticked box takes effect brings us right back to the original problem.

When someone saves a configuration section in the back office, Magento fires an admin_system_config_changed_section_<section> event. An observer listens to it, checks that one of the changed paths belongs to the list of exposed settings (about thirty today), rebuilds the configuration of the stores covered by the saved scope, and pushes it to the storefront with an authenticated POST.

The storefront stores that blob in a Redis shared by all its pods. Browsers re-read it on every navigation, at most once a minute. The response carries an ETag computed from the content: identical on every pod as long as nothing has changed, it lets the server return a bodyless 304 for the vast majority of those reads.

const etag = `W/"${createHash('sha1').update(JSON.stringify(storedConfig)).digest('base64')}"`
setResponseHeader(event, 'ETag', etag)

if (getRequestHeader(event, 'if-none-match') === etag) {
  setResponseStatus(event, 304)
  return null
}

Server routes read the same Redis. The search proxy, for example, gets each store’s word-dropping order from it, and only falls back to the build-time value when Redis does not answer.

Reading the value in the right place

A push is useless if the component has already copied the value. The incoming configuration is merged into Nuxt’s reactive appConfig: a component that reads the setting inside a computed updates on its own, while a component that read it once during setup keeps the old value until the next page load.

const countryConfig = useCountryConfig()

const defaultShippingMethod = computed(() => countryConfig.defaultShippingMethod || null)
const threshold = computed(() => defaultShippingMethod.value?.free_shipping_threshold || null)

That is what lets the free shipping threshold change for a shopper already browsing the site, without a reload. When we changed that threshold in France, the progress bar followed with no deployment.

Delivery and returns tab of a product page, listing the Standard, relay point and Express offers with their price and free-shipping threshold
The shipping offers on the product page are read from the store's Magento configuration, behind a switch that has been on by default since late September.

Four bugs, four lessons

The mechanism did not come out perfect. Each stage was born of a bug we had not seen coming.

February: streaming that would not hold. The first version pushed changes to browsers over Server-Sent Events. It needed a heartbeat, a forced reconnect every five minutes against zombie connections and careful cleanup on every disconnect: a lot of machinery for a setting that changes a few times a week. Before the site even launched, we replaced it with a plain read as the shopper navigates. Less elegant, far more robust.

April: a push that only reached one pod. The incoming configuration was kept in memory, in whichever pod received the POST. The others kept serving the old value, and a shopper could see the shipping threshold change depending on which pod answered. The fix fits in a Nitro plugin that mounts shared Redis storage:

storage.mount('store-config', redisDriver({
  base: 'polaris:store-config:',
  host: redis.host,
  connectTimeout: 3000,
  commandTimeout: 3000,
  maxRetriesPerRequest: 1,
}))

July: a save that wiped the other stores. A save scoped to a single store only emits that store. The storefront replaced the whole blob with whatever it received: after a save for Spain, only Spain kept its pushed configuration, and the other eight stores fell back to their build-time values. We now merge the incoming stores with the ones already stored.

September: an array that would not shrink. updateAppConfig merges arrays index by index. When a push sent a shorter list of shipping offers than the one baked at build time, the trailing build-time entries stayed on screen. We now truncate arrays before merging.

None of these bugs raised an error. All of them showed up on screen, on a single market, intermittently. That is the signature of distributed configuration problems.

The limit: what the server has already rendered

The live channel updates the browser. It does not rewrite HTML that has already been rendered. Server-side rendering starts from the build-time value, and that HTML is cached by the CDN. Opening a market therefore takes effect for each shopper at their next configuration read, but the first paint stays the build’s until the storefront is rebuilt.

In practice, our go-to-market runbook fits in one line: flip the switch, shoppers see it on their next navigation, and the market is cleanly open after the next build.

One case escapes the rule: a switch that decides what the server renders, such as which content system serves a page. Hydration has to find exactly the server’s choice, and a live flip would break the page. That kind of setting stays an environment variable, declared in the chart with a one-line note saying when it will be removed.

What it changes

Today, about thirty settings flow through this mechanism: the search engine mode and its word-dropping order, the Personalize surfaces one by one, the product types eligible for virtual try-on, the Size Finder, the delivery estimate, shipping offers on the product page, the free shipping threshold, and the number of page views before the newsletter pop-in.

What we learned

  • The right owner of a switch is the person who decides to flip it. Putting the box in the back office rather than in a chart changed who can launch a feature.
  • One source of truth. A Magento setting shadowed by an environment variable always drifts.
  • The default must reproduce the old behaviour. That is what makes a forgotten environment, or a build running against an older Magento, safe.
  • In a distributed system, caches are everywhere. Pod memory, CDN HTML, the image’s baked config, a value copied into a component: every bug came from a copy we had forgotten about.
  • Prefer the boring thing that works. One read a minute with an ETag replaced a real-time stream, and nobody has ever complained.