DevOps ·

Production Traffic Replay and Shadow Testing: Validating Changes Against Real User Traffic Without Risk

How to capture production traffic, replay it against new service versions, and compare results to validate changes before they reach users. Covers GoReplay, tcpdump, custom middleware, response diffing, and CI/CD integration.

Production Traffic Replay and Shadow Testing: Validating Changes Against Real User Traffic Without Risk

Canary deployments answer one question: does this version behave well under production traffic? Shadow testing answers a harder one: does it produce the same results?

Those two questions are not the same. A canary might pass your error-rate and latency thresholds while silently returning different responses. In a search service rewrite, wrong results do not cause 5xx errors. In a payments API migration, a subtly different fee calculation does not throw exceptions. Shadow testing catches that class of bug by running production traffic against the new version in parallel and comparing what each version returns.

This is the setup, the tooling decisions, the diff analysis patterns, and where the approach pays off beyond what canary can give you.

What Shadow Testing Actually Does

The mechanism is straightforward. Every incoming request is forked: one copy goes to the current production version (which serves the actual response to the user), and one copy goes to the shadow version (which processes the request but whose response is discarded). You record both responses and compare them.

The user never sees the shadow response. Their experience comes entirely from production. Shadow testing is a read from the user’s perspective, a write to your validation infrastructure. This means it is safe to run continuously, even on requests that mutate state, as long as you handle the shadow side carefully (more on that below).

The result is a corpus of paired responses: what production said versus what the shadow said, for real user traffic, under real load, without exposing anyone to unvalidated code.

Traffic Capture Techniques

Before you can replay or fork traffic, you need to capture it. Three approaches, from lowest to highest application involvement.

tcpdump and Packet Capture

tcpdump captures raw TCP traffic at the OS level. No application changes required. You can tap any service immediately:

# Capture HTTP traffic on port 3000, write to file
tcpdump -i eth0 -w /tmp/capture.pcap port 3000

# Watch live, filter to a specific host
tcpdump -i eth0 -A host api.internal and port 3000

The downside is parsing. You get raw bytes. Extracting HTTP request/response pairs, handling TLS (you cannot without terminating it elsewhere), and dealing with TCP stream reassembly is non-trivial work. tcpdump is useful for incident investigation and one-off captures but is not a production replay pipeline.

For TLS termination happens at a load balancer or edge, capture at the upstream side (between your load balancer and application) where traffic is unencrypted. This is the practical workaround for most setups.

GoReplay

GoReplay (gor) is purpose-built for HTTP traffic capture and replay. It captures traffic at the OS level, parses HTTP, and can replay, filter, and forward it with minimal setup.

# Mirror all production traffic to a shadow host
gor --input-raw :3000 \
    --output-http http://shadow.internal:3000 \
    --output-http-stats

# Replay at 2x speed to stress-test the shadow
gor --input-file /tmp/capture.gor \
    --output-http http://shadow.internal:3000 \
    --output-http-workers 10

# Mirror with rate limiting (10% of traffic)
gor --input-raw :3000 \
    --output-http http://shadow.internal:3000 \
    --output-http-track-responses \
    --http-pipelining \
    --split-output true

GoReplay also has a --middleware flag that lets you plug in a script to transform requests before forwarding them. This matters for API migrations where headers, paths, or request bodies have changed shape.

The limitation: GoReplay works at the HTTP layer. It does not understand your application’s internal structure. For gRPC, WebSocket, or binary protocols, you need something else.

Custom Middleware

For more control, instrument the application itself. A forking middleware layer sits inside your API gateway or application and clones every request:

import type { Request, Response, NextFunction } from "express";

interface ShadowConfig {
  shadowUrl: string;
  sampleRate: number; // 0.0 to 1.0
  timeout: number;    // ms, shadow requests should not block
}

function shadowMiddleware(config: ShadowConfig) {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (Math.random() > config.sampleRate) {
      return next();
    }

    // Clone the request body before express consumes it
    const bodyBuffer = await readBody(req);
    req.body = JSON.parse(bodyBuffer.toString());

    // Fork to shadow asynchronously, do not await
    fireShadowRequest(req, bodyBuffer, config).catch((err) => {
      // Shadow errors should never affect production
      console.error("shadow request failed", { err, path: req.path });
    });

    next();
  };
}

async function fireShadowRequest(
  req: Request,
  body: Buffer,
  config: ShadowConfig
): Promise<void> {
  const url = `${config.shadowUrl}${req.path}`;

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), config.timeout);

  try {
    const shadowRes = await fetch(url, {
      method: req.method,
      headers: {
        ...req.headers,
        host: new URL(config.shadowUrl).hostname,
        "x-shadow-request": "true",
        "x-shadow-request-id": req.headers["x-request-id"] as string ?? crypto.randomUUID(),
      },
      body: ["GET", "HEAD"].includes(req.method) ? undefined : body,
      signal: controller.signal,
    });

    const shadowBody = await shadowRes.text();

    // Emit comparison event for diff analysis
    emitComparisonEvent({
      requestId: req.headers["x-request-id"] as string,
      path: req.path,
      method: req.method,
      shadowStatus: shadowRes.status,
      shadowBody,
    });
  } finally {
    clearTimeout(timeoutId);
  }
}

async function readBody(req: Request): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

This approach gives you full control over sampling rate, request transformation, and what you emit for comparison. The key invariants: shadow requests must never block production responses, shadow errors must never propagate to users, and you must emit both sides’ results to a comparison store.

Response Diff Analysis

Capturing both responses is the easy part. Making sense of the differences is where the work lives.

A naive JSON diff of every response will flood you with noise: timestamps change, cursor tokens differ, ordering of unordered arrays varies. You need semantic diffing that understands what differences matter.

interface ResponseComparison {
  requestId: string;
  path: string;
  method: string;
  productionStatus: number;
  shadowStatus: number;
  productionBody: unknown;
  shadowBody: unknown;
}

interface DiffResult {
  statusMatch: boolean;
  structureMatch: boolean;
  semanticMatch: boolean;
  differences: Difference[];
}

interface Difference {
  path: string;
  production: unknown;
  shadow: unknown;
  severity: "critical" | "expected" | "cosmetic";
}

function compareResponses(comparison: ResponseComparison): DiffResult {
  const differences: Difference[] = [];

  // Status code check first
  const statusMatch = comparison.productionStatus === comparison.shadowStatus;
  if (!statusMatch) {
    differences.push({
      path: "$.status",
      production: comparison.productionStatus,
      shadow: comparison.shadowStatus,
      severity: "critical",
    });
  }

  // Deep structural comparison with known-volatile paths excluded
  const volatilePaths = new Set([
    "$.createdAt",
    "$.updatedAt",
    "$.timestamp",
    "$.requestId",
    "$.traceId",
    "$.cursor",
    "$.nextPageToken",
  ]);

  deepDiff(
    comparison.productionBody,
    comparison.shadowBody,
    "$",
    volatilePaths,
    differences
  );

  const criticalDiffs = differences.filter((d) => d.severity === "critical");
  const structureMatch = differences.every((d) => d.severity !== "critical" || d.path === "$.status");
  const semanticMatch = criticalDiffs.length === 0;

  return { statusMatch, structureMatch, semanticMatch, differences };
}

function deepDiff(
  a: unknown,
  b: unknown,
  path: string,
  volatile: Set<string>,
  out: Difference[]
): void {
  if (volatile.has(path)) return;

  if (typeof a !== typeof b) {
    out.push({ path, production: a, shadow: b, severity: "critical" });
    return;
  }

  if (Array.isArray(a) && Array.isArray(b)) {
    if (a.length !== b.length) {
      out.push({
        path: `${path}.length`,
        production: a.length,
        shadow: b.length,
        severity: "critical",
      });
      return;
    }
    // For arrays of objects with IDs, compare by ID not position
    const sortedA = sortIfIdentifiable(a);
    const sortedB = sortIfIdentifiable(b);
    sortedA.forEach((item, i) => deepDiff(item, sortedB[i], `${path}[${i}]`, volatile, out));
    return;
  }

  if (a !== null && b !== null && typeof a === "object" && typeof b === "object") {
    const keysA = new Set(Object.keys(a as object));
    const keysB = new Set(Object.keys(b as object));
    const allKeys = new Set([...keysA, ...keysB]);

    for (const key of allKeys) {
      const childPath = `${path}.${key}`;
      if (!keysA.has(key) || !keysB.has(key)) {
        out.push({
          path: childPath,
          production: (a as Record<string, unknown>)[key],
          shadow: (b as Record<string, unknown>)[key],
          severity: "critical",
        });
      } else {
        deepDiff(
          (a as Record<string, unknown>)[key],
          (b as Record<string, unknown>)[key],
          childPath,
          volatile,
          out
        );
      }
    }
    return;
  }

  if (a !== b) {
    out.push({ path, production: a, shadow: b, severity: "critical" });
  }
}

function sortIfIdentifiable(arr: unknown[]): unknown[] {
  if (arr.length === 0) return arr;
  const first = arr[0];
  if (first && typeof first === "object" && "id" in (first as object)) {
    return [...arr].sort((x: unknown, y: unknown) =>
      String((x as Record<string, unknown>).id).localeCompare(
        String((y as Record<string, unknown>).id)
      )
    );
  }
  return arr;
}

The volatile path exclusion list is the most important tuning parameter. Start broad, then narrow it as you understand which fields actually vary for non-semantic reasons. Every path you exclude is a blind spot, so document why each one is there.

Aggregate the diff results over time to produce a mismatch rate per endpoint. What you want is not a list of individual mismatches but a statistical view: this endpoint has a 0.3% mismatch rate, and these three field paths account for 80% of it.

CI/CD Integration

Shadow testing fits into a CI/CD pipeline in two modes.

Pre-deployment validation: before promoting a new version to production, run it in shadow mode against a replay of recent production traffic. If the mismatch rate exceeds a threshold, fail the pipeline.

Continuous validation: run shadow mode permanently against a percentage of live traffic. Monitor mismatch rates as a health signal, like you monitor error rates. Spikes indicate regressions or drift.

For pre-deployment validation with recorded traffic:

# .github/workflows/shadow-validation.yml
name: Shadow Validation
on:
  pull_request:
    branches: [main]

jobs:
  shadow-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build shadow image
        run: |
          docker build -t api-shadow:${{ github.sha }} .
          docker run -d --name shadow \
            -p 3001:3000 \
            -e DATABASE_URL=${{ secrets.STAGING_DATABASE_URL }} \
            api-shadow:${{ github.sha }}

      - name: Replay recent production traffic
        run: |
          # Pull last 30 minutes of captured requests from storage
          aws s3 cp s3://traffic-captures/recent.gor /tmp/replay.gor

          gor --input-file /tmp/replay.gor \
              --output-http http://localhost:3001 \
              --output-http-track-responses \
              --output-http-stats \
              2>&1 | tee /tmp/replay-output.log

      - name: Compare shadow responses against production baseline
        run: |
          node scripts/analyze-shadow-diff.js \
            --baseline s3://traffic-captures/baseline-responses.json \
            --shadow-log /tmp/replay-output.log \
            --threshold 0.01 \
            --output /tmp/diff-report.json

      - name: Fail on mismatch rate exceeded
        run: |
          MISMATCH=$(jq '.mismatch_rate' /tmp/diff-report.json)
          if (( $(echo "$MISMATCH > 0.01" | bc -l) )); then
            echo "Shadow mismatch rate ${MISMATCH} exceeds threshold 0.01"
            cat /tmp/diff-report.json
            exit 1
          fi

The key dependency is having a stored corpus of production responses to compare against. Capture production responses alongside requests: GoReplay’s --output-http-track-responses flag writes both sides to the output. Store these in object storage and rotate them on a schedule.

Shadow Testing vs. Canary vs. Blue-Green

These three strategies are often presented as alternatives to choose among. In practice they solve different problems and compose well.

StrategyReal Users AffectedValidates BehaviorValidates PerformanceComplexity
Blue-greenAll (on cutover)NoNoLow
CanarySubset (progressively)Partially (via metrics)YesMedium
Shadow testingNoneYes (response diff)Partially (under-load)High

Blue-green answers: can we switch traffic at all? It is the baseline deployment primitive. It does not tell you whether the new version produces correct results.

Canary answers: does the new version degrade observable quality metrics under real traffic? Error rates, latency, business funnels. It is blind to behavioral correctness if the new version fails silently.

Shadow testing answers: does the new version produce the same results? It catches behavioral regressions that do not surface as errors or latency changes. It does not protect users at all during continuous shadow mode, because users never see shadow responses.

The right combination for high-stakes changes: shadow test first (validate correctness against recorded traffic in CI), then canary deploy (validate performance and error rates under live traffic), then full rollout. For low-risk changes, canary alone is often sufficient.

Shadow testing specifically earns its complexity cost in these scenarios:

  • API migrations: rewriting a service from one framework or language to another. Behavioral equivalence is the whole question.
  • Database schema changes: validating that a query rewrite against a new schema returns identical results to the original.
  • Algorithm changes: search ranking, recommendation engines, pricing calculations. The output is the feature, and wrong is invisible to error-rate monitoring.
  • Dependency upgrades: major version bumps in libraries that affect serialization, parsing, or computation.

Handling Stateful Requests in Shadow Mode

The most dangerous failure mode: a shadow request writes to a real database, sends a real email, charges a real payment, or modifies shared state that production reads.

Mitigation strategies, in order of preference:

Separate shadow databases. The shadow service reads and writes to a replica or a separate test database. Shadow mutations are isolated. The tradeoff is that shadow reads may return different data than production reads, which introduces its own noise into the diff analysis. For most services, this is acceptable because you are validating response shape and logic, not exact data values.

Request filtering. Only shadow read-only endpoints. Skip POST, PUT, DELETE, PATCH at the middleware layer. This limits coverage but eliminates the mutation risk entirely. Practical for services where the interesting logic lives in GET endpoints (search, recommendations, listings).

Dry-run mode. Add a request header (x-shadow-request: true) that the application handles by executing the business logic but skipping external side effects: database writes, email sends, third-party API calls. Requires application-level cooperation but gives you full coverage without real mutations. This is the cleanest solution for services you own and control.

// In your request handler
async function createOrder(req: Request): Promise<Order> {
  const isShadow = req.headers["x-shadow-request"] === "true";

  // Validate and compute the order regardless
  const order = await buildOrder(req.body);
  await validateInventory(order);
  const pricing = await calculatePricing(order);

  if (!isShadow) {
    // Only write and notify in production mode
    await db.orders.create(order);
    await paymentService.charge(pricing);
    await notificationService.sendConfirmation(order);
  }

  return { ...order, pricing };
}

The dry-run pattern requires discipline: every place in your codebase that performs a side effect must respect the shadow flag. Make the flag an ambient context value (via AsyncLocalStorage in Node.js) rather than threading it through every function signature.

Production Considerations

Performance overhead. Shadow requests consume network bandwidth and CPU on the shadow host. For high-throughput services, shadow at 5-10% of traffic rather than 100%. The diff analysis pipeline needs separate capacity from your application infrastructure. Keep them isolated so shadow analysis slowdowns cannot affect production observability.

Data sensitivity. Production traffic contains real user data: personally identifiable information, session tokens, credentials. Shadow infrastructure must meet the same security and compliance standards as production. Do not route production traffic to shadow environments that lack proper access controls, encryption, or data retention policies.

Diff noise baseline. When you first start shadow testing, you will see a high mismatch rate. Most of it is noise: volatile timestamps, cursor tokens, non-deterministic ordering. Spend time building your volatile path exclusion list before trusting the mismatch rate as a signal. Target less than 1% background mismatch rate before treating shadow validation as a quality gate.

Clock skew between environments. If your shadow environment has clock-based behavior (token expiry, rate limiting with time windows, time-bucketed aggregations), responses may differ legitimately due to timing rather than logic differences. Normalize time-derived fields in your diff logic or use test clocks in the shadow environment.

Traffic representativeness. Captured traffic reflects the requests your users actually sent. It does not cover edge cases that have not happened yet. Shadow testing is not a replacement for unit tests, integration tests, or explicit edge case coverage. It catches regressions against known traffic patterns, not novel inputs.

Shadow environment parity. If the shadow environment runs a different version of a database, cache, or external service than production, diffs may reflect environment differences rather than code differences. This is the hardest operational challenge in shadow testing. Maintain shadow environments carefully, especially around schema migrations and dependency upgrades.

The Payoff

The teams that get the most value from shadow testing are the ones running large migrations: REST to GraphQL, Python to Go, PostgreSQL to a distributed database, monolith to microservices. In all these cases, the core claim is behavioral equivalence, and behavioral equivalence is precisely what shadow testing validates.

A service rewrite that passes all unit tests, integration tests, and canary deployment can still be wrong in ways that only show up in the diversity of real production traffic. Real users send requests you did not think to test. They hit edge cases in your data that staging does not have. They combine parameters in ways your test suite never exercised.

Shadow testing exposes the new version to that diversity before anyone depends on its output.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.