Chaos Engineering for Headless CMS Dependency Failures

Part of Automated Testing for Headless Integrations, chaos engineering for CMS dependencies injects controlled degradation — latency, 5xx, truncated payloads — into the data pipeline to prove that fallback routing, circuit breakers, and stale-content serving actually fire before a real outage tests them. Most headless stacks treat the content API as always-available, but in production GraphQL/REST endpoints throttle, partition by region, drift their schema, and return malformed JSON. When they degrade, frontend hydration stalls, ISR revalidation queues saturate, and CDN caches fail open. The goal isn’t uptime; it’s verifying graceful degradation when the CMS is partially or fully down.

Root-Cause Analysis

Cascading failures trace to three untested assumptions in the data-fetching layer:

  1. Retry loops without backoff or caps. Default linear/exponential retries exhaust connection pools and trip CMS rate limits (HTTP 429). Without jitter and a hard cap, retries turn a partial outage into a full denial.
  2. No timeout boundary at the edge. CDN routing and ISR handlers wait indefinitely for 200 OK. Build workers and serverless functions hang, burn execution quota, and block deploys.
  3. No typed fallback contract. On 5xx or truncated JSON, components hit undefined property access and unhandled rejections that crash client rendering.

The root cause is treating a volatile third-party endpoint as infallible. Without fault-domain isolation, one CMS hiccup propagates straight into SSR, static generation, and client hydration.

The resilience layers that contain injected degradation:

Resilience layers that contain injected degradationInjected faults pass through the MSW layer into a circuit breaker; recovered calls serve fresh content, timeouts fall back to cached ISR slugs, and origin errors are absorbed by the CDN's stale-if-error copy or a typed fallback UI, all asserted in CI.Inject faultlatency, 5xx, truncationCircuit breakerretry + jitterFresh contentISR fallbackcached slugsCDNstale-if-errorTyped fallback UIrecoveredtimeoutorigin 5xxno cache
Each fault class has exactly one layer responsible for absorbing it, and a CI assertion for that layer.

Step-by-Step Resolution

Inject chaos at the network-interception layer, set resilience policies in the fetch client, and assert ISR/CDN fallback under simulated degradation.

1. Intercept CMS Traffic with Mock Service Worker

Proxy CMS calls through a layer that injects latency, 503/504s, and malformed payloads. Run it in CI and staging — never against production endpoints.

TypeScript
// msw/handlers/cms.ts
import { http, HttpResponse, delay } from 'msw';

export const cmsHandlers = [
  http.get('https://api.cms-provider.com/graphql', async ({ request }) => {
    // Inject 30% chance of 503 Service Unavailable
    if (Math.random() < 0.3) {
      return HttpResponse.json(
        { error: 'Upstream throttled' },
        { status: 503, headers: { 'Retry-After': '5' } }
      );
    }

    // Inject 20% chance of 2s+ latency
    if (Math.random() < 0.2) {
      await delay(2500);
    }

    // Return valid or intentionally truncated payload
    return HttpResponse.json({
      data: {
        page: {
          title: 'Landing Page',
          hero: { /* ... */ }
        }
      }
    });
  })
];

For wiring this into your test runner deterministically, see Automated Testing for Headless Integrations.

2. Enforce Circuit Breaker Logic in Fetch Clients

Set retry limits, exponential backoff with jitter, and explicit failure thresholds. Disable retries on non-idempotent mutations so a partial outage doesn’t produce duplicate writes.

TypeScript
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const resilientQueryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 2,
      retryDelay: (attemptIndex) =>
        Math.min(1000 * 2 ** attemptIndex + Math.random() * 200, 5000),
      staleTime: 1000 * 60 * 5,
      gcTime: 1000 * 60 * 15,
      throwOnError: false, // Prevents unhandled promise rejections in UI
    },
    mutations: {
      retry: 0, // Never retry POST/PUT/PATCH during CMS degradation
    },
  },
});

3. Validate ISR Timeout Boundaries

Wrap getStaticProps or route handlers in an AbortController timeout. When the CMS exceeds your latency SLA, fall back to cached data so build workers never hang.

TypeScript
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

const CMS_TIMEOUT_MS = 2000;

export async function generateStaticParams() {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), CMS_TIMEOUT_MS);

  try {
    const res = await fetch('https://api.cms-provider.com/graphql', {
      signal: controller.signal,
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ query: '{ pages { slug } }' }),
    });

    if (!res.ok) throw new Error(`CMS responded ${res.status}`);
    const json = await res.json();
    return json.data.pages.map((p: { slug: string }) => ({ slug: p.slug }));
  } catch (err) {
    // Fallback to pre-cached slugs or empty array for graceful build completion
    console.warn('ISR param fetch failed, using fallback:', err);
    return [];
  } finally {
    clearTimeout(timeout);
  }
}

Align these timeouts with your CDN’s stale-while-revalidate windows; the Next.js data fetching & caching docs cover how revalidation and cache headers interact.

A build worker with and without a CMS timeoutWith no timeout, a build worker waits for a hung CMS request until the platform kills it at 900 seconds; with a two-second AbortController timeout it falls back and finishes the build in seconds.No timeout: worker waitskilled by platform limit2 s timeout + fallback0 s200 s400 s600 s800 sabort
The timeout turns an outage from a failed deploy into a deploy with slightly stale content.

4. Test CDN Routing Under Partial Outage

Set cache headers that let edge nodes serve expired content when origin health checks fail. This prevents an invalidation storm during degradation.

HTTP
Cache-Control: public, max-age=300, stale-while-revalidate=86400, stale-if-error=604800
  • max-age=300: Fresh content served for 5 minutes.
  • stale-while-revalidate=86400: Background revalidation allowed for 24 hours.
  • stale-if-error=604800: Serve stale content for up to 7 days if origin returns 5xx or times out.
Share of requests answered during a simulated 20-minute CMS outagePercentage of page requests that returned usable content during an injected 20-minute origin outage under three cache header configurations.max-age=300 only38 %+ stale-while-revalidate61 %+ stale-if-error99 %The remaining 1 percent were URLs never cached before the outage began.
Measured in staging with the origin returning 503 for 20 minutes and synthetic traffic across 200 URLs.

Simulate origin downtime and assert the CDN returns X-Cache: HIT or STALE rather than propagating 502 Bad Gateway to users.

5. Automate Chaos Scenarios in CI

Run fault injection in pre-deploy pipelines and assert that components render fallback states within a latency budget. Use Playwright or Cypress to check DOM structure and accessibility attributes during a simulated outage.

TypeScript
// tests/cms-fallback.spec.ts
import { test, expect } from '@playwright/test';

test('renders fallback UI when CMS returns 503', async ({ page }) => {
  // MSW intercepts and returns 503 automatically in CI
  await page.goto('/blog/chaos-engineering');
  
  await expect(page.locator('[data-testid="cms-fallback-banner"]')).toBeVisible();
  await expect(page.locator('h1')).toHaveText('Content Temporarily Unavailable');
  
  // Verify hydration completed without client-side errors
  const consoleErrors = await page.evaluate(() => window.__consoleErrors || []);
  expect(consoleErrors).toHaveLength(0);
});

Production Deployment & Observability

Control the blast radius. Enable fault injection in staging first, then roll into production behind a feature flag scoped to internal traffic or low-risk routes. Track three resilience metrics:

  • Error budget consumption: 5xx rate and timeout percentage against SLA.
  • Fallback activation rate: how often stale-if-error or client fallbacks fire.
  • Hydration mismatch count: target zero React hydration warnings while degraded.

Instrument the fetch layer with distributed tracing to correlate CMS latency spikes with render degradation. Run this alongside the rest of your Data Fetching & Caching Strategies and a CMS dependency stops being a single point of failure.

Configuration Reference

Parameter Value Why
Fault rate in CI 30 % 503, 20 % 2.5 s latency High enough to hit every code path in a short run.
Fetch timeout 2 s at build, 3 s at request time Below the platform function limit with room for fallback work.
Retries 2, exponential with jitter, capped at 5 s Recovers from blips without amplifying an outage.
Mutation retries 0 Prevents duplicate writes during partial failures.
stale-if-error 7 days Lets the edge ride out long outages for cached pages.
Seeded randomness fixed seed per CI run Makes a failing chaos run reproducible.

Seed the fault injection. Math.random() in the handlers above makes each run different, which is useful for exploratory runs but painful when a CI failure cannot be reproduced. Replace it with a seeded generator whose seed is printed in the test output, so any failing run can be replayed exactly.

Gotchas & Edge Cases

  • Chaos against the vendor. Never point fault injection or load at the CMS vendor’s production API. Inject at your own interception layer, proxy or service mesh.
  • Fallbacks that hide outages. A perfect fallback can mask a days-long CMS problem. Emit a metric every time a fallback path serves content, and alert on sustained activation.
  • Empty generateStaticParams in production builds. Returning an empty array on failure builds zero pages, which is safe only with on-demand rendering enabled. With dynamicParams = false, it would deploy a site full of 404s; fail the build instead in that configuration.
  • Truncated JSON parses as an error, not a partial object. Test the truncated case explicitly: the fetcher’s res.json() throws, which must go down the same fallback path as a 5xx.
  • Tests that pass because of retries. A 30 % failure rate with two retries still succeeds 97 % of the time. Add a handler variant that fails every request, so the fallback path is always exercised.

Running a First Game Day

Start small. Pick one high-traffic page type, write down what should happen when the CMS returns 503 for ten minutes, and run exactly that scenario in staging with the team watching dashboards. Typical first findings are a fallback banner that shifts layout, a build that hangs because one fetch lacked a timeout, and an alert that never fired. Fix those, add the scenario to CI, and only then widen the blast radius to latency and truncated payloads.

Frequently Asked Questions

Is chaos engineering worth it for a marketing site?

A lightweight version is. Two tests, one rendering each page type with the CMS returning 503 and one checking cache headers for stale-if-error, catch most outage-time failures at almost no cost. Full game days make sense once the site carries revenue or legal content.

Where should fault injection live in production?

Behind a feature flag at your own proxy or gateway, scoped to internal traffic or a synthetic user, and never in the browser. Production experiments should be short, observed, and have an instant kill switch.

How do I know the fallback UI is good enough?

Treat it as a product state and review it like one: it should keep navigation working, explain the situation briefly, and avoid layout shift when content returns. Visual regression tests with a 503 fixture keep it from quietly breaking.

How is this different from load testing?

Load testing asks how much traffic the stack handles when everything works. Chaos testing asks what readers see when a dependency misbehaves at normal traffic. Both matter, and they find different bugs: load tests find capacity limits, chaos tests find missing timeouts, broken fallbacks and alerts that never fire.