Each failure below traces to a specific, documented vendor limit — not to the headless approach being immature. This was checked against Next.js 16.2.12, Vendure Core 3.7.1, Saleor Core 3.23.22 and PCI DSS v4.0.1; Shopify’s checkout-extensibility gating was confirmed against shopify.dev on 2026-07-27 and has no single published version number, so reconfirm it before you rely on it. Test each seam in staging before launch, because every one of these fails silently in production first.
How many vendors does a headless commerce stack actually add
Headless replaces one platform with a set of independently versioned vendor contracts: a content management system (CMS, the tool editors use to write and publish content), a search index, a payment or checkout provider, and an orchestration layer that stitches the calls together. Most stacks land at four to seven vendors once you count each one separately. That is not a side effect of decoupling — it is the cost decoupling buys, and it is worth pricing before you commit to the architecture.
Each vendor ships on its own release schedule and has its own incident history, independent of your platform’s uptime. A CMS outage, a search-index reindex backlog and a payment gateway timeout are three unrelated failures with three unrelated on-call paths, not one platform issue with three symptoms.
Open-source commerce engines make the pattern concrete. Vendure Core 3.7.1 and Saleor Core 3.23.22 both ship a commerce API with no CMS and no search index bundled in — those become separate vendor decisions from day one, not later additions. The same split applies whether you buy SaaS vendors or run open source yourself.
The acronym behind most of this marketing is MACH — Microservices-based, API-first, Cloud-native SaaS, and Headless — as defined in the MACH Alliance’s own standards repository (opens in a new tab). The Alliance’s current public site reframes the same idea around three principles instead: Open, Composable, Connected (opens in a new tab). A vendor citing “MACH” in 2026 might mean either framing, depending on when its marketing copy was last updated — worth checking before you take the label as a spec.
Before reading the failure modes below, write down which vendor in your own stack owns each of the four contracts — CMS, search, checkout, orchestration. Every failure that follows traces back to one of those four owners.
Why CMS preview content diverges from what visitors see
Preview and production are two different caches, not one cache with a preview flag. Next.js 16.2.12 Draft Mode (opens in a new tab) sets a signed cookie scoped to the previewing browser’s own session. Every other visitor keeps hitting the CDN or Incremental Static Regeneration (ISR) cache, which that cookie never touches — so an editor can see a change instantly while the public sees it minutes, hours, or never, later.
If the CMS serves draft content from a different endpoint than published content, your app has to branch its fetch call on the isEnabled flag explicitly. Nothing about Draft Mode makes that branching automatic — skip it and the preview simply calls the published endpoint under a different cookie, showing nothing new.
import { draftMode } from 'next/headers';
export async function getPage(slug: string) {
const { isEnabled } = await draftMode();
const endpoint = isEnabled
? `https://cms.example.com/preview/pages/${slug}`
: `https://cms.example.com/published/pages/${slug}`;
const token = isEnabled
? process.env.CMS_PREVIEW_TOKEN
: process.env.CMS_DELIVERY_TOKEN;
const res = await fetch(endpoint, {
headers: { Authorization: `Bearer ${token}` },
cache: isEnabled ? 'no-store' : 'force-cache',
});
return res.json();
}Contentful makes the same split explicit at the API level. Draft or unpublished entries are only readable through the Content Preview API or the Content Management API (opens in a new tab) — each authenticated with its own token, separate from the standard Content Delivery API token. A component that must render correctly in both contexts needs two client configurations, not one flag.
{
"delivery": {
"space": "<YOUR_SPACE_ID>",
"accessToken": "<YOUR_DELIVERY_API_TOKEN>",
"host": "cdn.contentful.com"
},
"preview": {
"space": "<YOUR_SPACE_ID>",
"accessToken": "<YOUR_PREVIEW_API_TOKEN>",
"host": "preview.contentful.com"
}
}The failure mode this produces: an editor publishes, the CMS fires a webhook to trigger revalidation, the webhook call fails silently, and the live page keeps serving stale content with no error visible to the editor. Storyblok’s visual editor (opens in a new tab) has the same publish-then-revalidate dependency underneath a different UI.
Test this before launch: break the publish webhook deliberately in staging, publish a change, and time how long the stale page persists before anything surfaces the discrepancy. If nothing alerts within your acceptable staleness window, that is the gap to close before go-live, not after.
You should now see: a reproducible stale-preview scenario in staging, with the specific webhook or cache layer responsible identified before it happens in production.
Why search results lag after a catalogue update
A headless search index is a replica, not a live view of the catalogue — treat it as eventually consistent from the start. Reindexing runs asynchronously off a webhook or event queue sitting outside the catalogue’s own write transaction, so a price or stock change lands in the source of truth immediately but reaches search on the queue’s own schedule.
That schedule is not fixed. A single-item update can queue behind an in-progress full catalogue sync, so the visible staleness window varies run to run rather than holding at some constant number of seconds. Elastic Path’s own documentation of its Algolia integration (opens in a new tab) shows the same webhook-triggered, queued pattern — this holds regardless of which specific search vendor sits behind your storefront.
The fix is not eliminating the lag — it is agreeing a maximum acceptable staleness window with the vendor and measuring against it, particularly for order-affecting fields like stock and price. Re-fetch those two fields from the commerce API at add-to-cart and checkout time rather than trusting the search result, since that is where a stale value turns into an overselling incident or a wrong charge.
Measure the actual window directly instead of guessing it:
Every 5s, for SKU-12345:
GET https://commerce.example.com/api/products/SKU-12345 -> record price, timestamp
GET https://search.example.com/api/index/products/SKU-12345 -> record price, timestamp
At t=0: update SKU-12345 price in the catalogue via the admin API
Log both timestamps on every poll until the two prices match
Divergence window = (time search matches catalogue) - (time catalogue changed)Run that against your own stack for a few update cycles and take the worst case, not the average — a queue that occasionally backs up behind a full sync is the case that will page someone.
You should now see: a measured staleness window, in minutes, that you can compare directly against the business’s tolerance for stock and price accuracy.
Why Shopify Hydrogen can’t fully replace Shopify’s checkout
A headless storefront rebuild does not automatically buy checkout ownership — confirm the extension points before promising a fully custom checkout. Shopify’s own checkout app extensions documentation (opens in a new tab) restricts UI extensions on the information, shipping and payment steps to stores on a Shopify Plus plan (confirmed against shopify.dev on 2026-07-27; Shopify publishes no single Checkout Extensibility version number and can change plan gating without one, so reconfirm this at publication). A Hydrogen storefront can rebuild every other page and still hit that wall.
Extensions on the Thank You and Order Status pages are open to all plans except Shopify Starter — a materially smaller surface than information, shipping and payment. If a stakeholder wants a custom shipping-method picker or custom payment validation and the store isn’t on Plus, that requirement is not buildable regardless of storefront architecture.
commercetools draws the same line differently. Its Checkout product (opens in a new tab) ships two integration modes, Complete Checkout and Payment Only, and its own documentation states Payment Only provides no address forms and no shipping-method selection. A team adopting Payment Only purely to handle payments still has to build or source the rest of the checkout flow itself — the vendor did not remove that scope, it relocated it.
Vendure takes a different path again: its Shop API checkout flow (opens in a new tab) is fully composable by design, since Vendure has no hosted checkout UI to gate in the first place — the tradeoff there is that you own the entire flow, not that Vendure restricts part of it.
flowchart TD
A[Need: fully custom checkout] --> B{Which vendor?}
B -->|Shopify| C{On Plus plan?}
C -->|No| D[Info/shipping/payment steps locked]
C -->|Yes| E[Info/shipping/payment extensible]
B -->|commercetools| F{Which mode?}
F -->|Payment Only| G[Must build cart, address, shipping yourself]
F -->|Complete Checkout| H[Full flow provided by commercetools]
B -->|Vendure| I[No hosted checkout UI - build everything]
Before scoping a “fully custom checkout” commitment to a stakeholder, verify which steps the plan tier or vendor mode actually exposes. That is a five-minute documentation check that prevents a mid-project discovery that a required change simply cannot ship.
You should now see: a plan-tier and extension-point matrix for your chosen vendor, confirming exactly which checkout steps can and cannot be customised.
Why a custom checkout page can push you out of SAQ A
Adding one script tag to the checkout page can reclassify your entire PCI compliance burden. Self-Assessment Questionnaire A (SAQ A) is the smallest PCI DSS compliance scope available, and PCI DSS v4.0.1 (opens in a new tab) — published 11 June 2024 and the only PCI SSC-supported version since v4.0 retired on 31 December 2024 — is the version in force now. SAQ A eligibility requires that the merchant’s checkout page never receive, process, transmit or store cardholder data itself, with the card fields living entirely inside a processor-hosted iframe or redirect.
The clause teams miss is a second condition: the official SAQ A document (opens in a new tab) also requires that the merchant never control the checkout page’s own scripts. That requirement is separate from where the card data itself lives.
Adding custom code, an analytics tag or a retargeting pixel to the checkout page reopens the scope question, even when the card fields stay safely inside the processor’s iframe the whole time. The PCI Security Standards Council’s merchant guidance (opens in a new tab) frames script control as part of the assessment, not an afterthought to it.
sequenceDiagram
participant M as Merchant checkout page
participant P as Processor-hosted iframe
participant A as PCI assessor
M->>P: Card fields render inside iframe only
Note over M: Merchant adds analytics/retargeting script
M->>A: Annual SAQ submission
A->>M: Script control question fails SAQ A
A->>M: Reclassified to SAQ A-EP (larger scope)
Reclassification into SAQ A-EP is a materially larger assessment than SAQ A, with more controls to document and evidence. The practical check before adding anything to that page: audit every script tag actually present on it, not only the ones belonging to the payment form. A marketing team adding a pixel without looping in engineering is the most common way this happens.
You should now see: a full list of every third-party script currently loaded on your checkout page, with each one flagged for SAQ A-EP risk or cleared.
Why webhook retries cause duplicate charges or emails
A webhook handler that doesn’t check for duplicates will eventually double-charge someone — this is a documented delivery guarantee, not an edge case. Stripe’s webhook documentation (opens in a new tab) states delivery is guaranteed at-least-once, never exactly-once, and never in order. Live-mode retries continue for up to three days with exponential backoff, so a slow or briefly-down endpoint can receive the same event many times over that window.
Stripe’s own guidance is explicit: deduplicate by event.id before mutating any state. Most duplicate-charge and duplicate-email bugs trace directly to skipping that one check, not to anything unusual about the failure that triggered the retry.
async function handleStripeWebhook(event: { id: string; type: string; data: unknown }) {
const alreadyProcessed = await db.processedEvents.findUnique({
where: { eventId: event.id },
});
if (alreadyProcessed) {
return { status: 200, body: 'already processed' };
}
await processEvent(event);
await db.processedEvents.create({
data: { eventId: event.id, processedAt: new Date() },
});
return { status: 200, body: 'ok' };
}Not every headless checkout is async, though, and the failure shape changes with the architecture. Saleor Core 3.23.22’s payment-app checkout flow (opens in a new tab) runs on synchronous webhooks: mutations like paymentGatewayInitialize and transactionInitialize block the checkout mutation on the payment app’s own response. A slow app there fails checkout in real time as a timeout, rather than producing a retry storm after the fact — the fix for that failure mode is app-side latency budgets, not idempotency keys.
You should now see: the exact line in your webhook handler that checks event.id before mutating order or payment state — or a confirmed gap where that check is missing.
Why page latency multiplies when the storefront calls every vendor directly
Individually fast vendor APIs do not add up to a fast page — without an aggregating layer, page latency becomes the sum, or at best the slowest leg, of several independently hosted services instead of one origin’s response time. Each additional direct call the storefront makes to a CMS, a search index or a commerce engine adds its own round trip, its own timeout risk and its own point of failure to every render.
The standard mitigation is the Backend for Frontend (BFF) pattern: one API layer per client that aggregates and reshapes calls to each downstream vendor, documented in Microsoft’s Azure Architecture Center (opens in a new tab). A BFF lets one slow vendor degrade a section of the page instead of the whole render, and gives you one place to cache per-vendor responses independently. GraphQL federation setups solve a related problem — distributed orchestration (opens in a new tab) across subgraphs — worth knowing if your stack already runs GraphQL rather than building a BFF from scratch.
flowchart LR
subgraph Direct["Without a BFF"]
S1[Storefront] --> C1[CMS API]
S1 --> R1[Search API]
S1 --> M1[Commerce API]
end
subgraph Aggregated["With a BFF"]
S2[Storefront] --> B[BFF layer]
B --> C2[CMS API]
B --> R2[Search API]
B --> M2[Commerce API]
end
Client-side-rendered content carries a second, separate cost: Google’s rendering phase. Per Google Search Central’s documentation (opens in a new tab), rendering runs as a headless Chromium pass queued independently after crawling, as a distinct step in the crawl-render-index pipeline. A page can look correct to a human in a browser and still be absent from search results while it waits its turn in that queue.
Real User Monitoring is the tool to watch specifically during a migration from a monolithic platform to a composable one — track Time to First Byte (TTFB) and Largest Contentful Paint (LCP) before and after, since these are the metrics most likely to regress even when every individual vendor benchmarks fast in isolation.
You should now see: a clear answer for whether your storefront calls vendor APIs directly per render or through an aggregating layer, plus the specific RUM metric you’re watching for regression.
A pre-launch checklist for naming who owns each failure
Every failure mode above has the same shape: a vendor-documented limit, a reproducible test, and an owner who should already know both before launch. Build one table before go-live, covering preview staleness, search lag, checkout gating, PCI scope, webhook duplication and latency — for each row, name the vendor responsible, the test that reproduces the failure, and the on-call path when it happens in production.
Failure mode | Owning vendor | Reproduction test | On-call path
---------------------|----------------------|---------------------------------------------|------------------
Preview staleness | CMS + revalidation | Break publish webhook in staging, time lag |
Search lag | Search index vendor | Poll catalogue vs search on SKU update |
Checkout gating | Checkout provider | Check plan tier against extension docs |
PCI scope | Checkout page owner | Audit every script tag on checkout |
Webhook duplication | Payment provider | Replay same event.id twice, check for dupes |
Render latency | Orchestration layer | RUM TTFB/LCP before vs after migration |Price the integration and on-call burden into the build alongside the software licence for each vendor — this is the cost vendor content routinely leaves out, and it is the number stakeholders actually need before approving a headless migration.
Limitations
This does not cover data-residency or GDPR vendor contracts, which introduce a sixth failure category of their own and were out of scope here. Shopify Checkout Extensibility has no single published version number, so the plan-gating facts above must be reconfirmed against shopify.dev at the time you read this, not assumed stable from this write-up. No multi-region latency testing or vendor SLA numbers were measured directly for this article — every figure cited comes from vendor documentation, not independent benchmarking, and your own numbers may differ by region and load.