Skip to content

Production Safety

Scenarist is safe to use in production - your test mocking code will never reach production users.

When you deploy to production with NODE_ENV=production, Scenarist automatically returns undefined without loading any test code. Your bundler (Webpack, Vite, esbuild, etc.) then performs tree-shaking to eliminate all Scenarist code from your production bundle.

import { createScenarist } from "@scenarist/express-adapter";
import { scenarios } from "./scenarios";
// In production: returns undefined
// In development/test: returns working Scenarist instance
export const scenarist = createScenarist({
enabled: true,
scenarios,
});

All Scenarist adapters use a production wrapper that checks NODE_ENV before loading any test code:

// Simplified view of what happens inside createScenarist()
export const createScenarist = (options) => {
if (process.env.NODE_ENV === "production") {
return undefined; // ← Returns immediately, no test code loaded
}
// Development/test: load implementation
return createScenaristImpl(options);
};

This pattern ensures:

  • Zero runtime impact - Production code paths never execute test logic
  • Automatic tree-shaking - Bundlers eliminate dead code
  • No configuration needed - Works automatically based on NODE_ENV
  • Type-safe - TypeScript enforces null checks via | undefined return type

Production bundles contain zero Scenarist code - the production wrapper pattern combined with tree-shaking completely eliminates all test code from your production builds.

Critical: Always verify tree-shaking is working in your production builds. This section provides multiple methods to confirm Scenarist code is eliminated.

The fastest way to verify tree-shaking by searching for MSW runtime functions (not just the word “msw”):

Terminal window
# Build for production
NODE_ENV=production npm run build
# Search for MSW runtime code (setupWorker, HttpResponse, handler functions)
! grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/ .next/static/ build/

Expected result: No matches found (exit code 0 from the command due to ! negation). If matches are found, tree-shaking failed.

Why this pattern: Searching for the literal strings “scenarist” or “msw” gives false positives (your own variable names, Zod code, comments). This pattern searches for actual MSW runtime functions like http.get(), HttpResponse.json(), and worker setup - code that should NEVER appear in production bundles.

Visual analysis tools provide the most comprehensive verification. Choose the tool matching your bundler:

Next.js (Webpack):

Terminal window
# Install analyzer
npm install --save-dev @next/bundle-analyzer
# Add to next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// ... your config
});
# Build and analyze
ANALYZE=true NODE_ENV=production npm run build

Opens interactive visualization in browser. Search for “scenarist” or “msw” - should find nothing.

Vite:

Terminal window
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
});
# Build and analyze
NODE_ENV=production npm run build

Opens stats.html showing bundle composition. Scenarist/MSW should be absent.

Webpack (standalone):

Terminal window
# Install analyzer
npm install --save-dev webpack-bundle-analyzer
# Generate stats during build
webpack --mode production --profile --json > stats.json
# Analyze
npx webpack-bundle-analyzer stats.json

Rollup:

Terminal window
# Install visualizer
npm install --save-dev rollup-plugin-visualizer
# Add to rollup.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [
visualizer({
filename: 'bundle-stats.html',
open: true,
}),
],
};
# Build
NODE_ENV=production npm run build

Works with any bundler:

Terminal window
# Install source-map-explorer
npm install --save-dev source-map-explorer
# Build with source maps
NODE_ENV=production npm run build
# Analyze (adjust paths to your build output)
npx source-map-explorer 'dist/**/*.js' 'dist/**/*.js.map'

Generates treemap showing code origins. Scenarist/MSW should not appear.

Measure actual bundle size impact:

Terminal window
# Baseline: Build without Scenarist
# (Comment out scenarist imports temporarily)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Note the size
# With Scenarist: Build normally
# (Uncomment scenarist imports)
NODE_ENV=production npm run build
du -sh dist/ .next/static/ build/
# Compare sizes - should be identical or negligible difference

Expected: ≤ 1KB difference (rounding/metadata only)

Express (Automatic for Unbundled, Configuration for Bundled):

Unbundled Deployments (Most Common):

Standard Express deployment pattern - no bundler involved:

Terminal window
# Deploy directly
NODE_ENV=production node src/server.js

How it works:

  • createScenarist() returns undefined at runtime
  • Dynamic imports never execute
  • MSW code never loads into memory
  • Zero configuration required

Verify with memory inspection (optional):

Terminal window
# Run production server and check memory
NODE_ENV=production node --trace-warnings src/server.js &
PID=$!
# MSW should not appear in loaded modules
lsof -p $PID | grep -i msw
# Should output nothing
kill $PID

Bundled Deployments (esbuild, webpack, Vite, rollup):

Default Approach (Zero Config):

Most bundlers enable code splitting by default for dynamic imports:

Terminal window
# esbuild with code splitting
esbuild --bundle --splitting --outdir=dist --define:process.env.NODE_ENV='"production"'
# webpack (code splitting automatic for dynamic imports)
webpack --mode production
# Vite (code splitting automatic)
vite build
# rollup (code splitting automatic for dynamic imports)
rollup -c

How it works:

  1. Dynamic import creates code-splitting boundary
  2. Impl code split into separate chunk (e.g., impl-ABC123.js)
  3. DefinePlugin makes if (process.env.NODE_ENV === 'production') unreachable
  4. Import statement eliminated from entry point
  5. Chunk file exists on disk but never loads into memory

Verification:

Terminal window
# Build with code splitting
NODE_ENV=production npm run build
# Entry point should be small (~27kb)
ls -lh dist/server.js
# Impl chunk should exist but never be loaded
ls -lh dist/impl-*.js # Exists (~242kb)
# Verify chunk never loads into memory (optional)
NODE_ENV=production node dist/server.js &
PID=$!
lsof -p $PID | grep 'impl-.*\.js'
# Should output nothing (chunk not loaded)
kill $PID

Result:

  • Entry point: ~27kb (94% smaller than single bundle)
  • Impl chunk: ~242kb (exists but never loaded)
  • Effective delivery: 27kb (same as conditional exports!)
  • Zero custom configuration required

Optional: Minimal Build Artifacts

Scenarist uses conditional package.json exports to provide different entry points:

{
"exports": {
".": {
"production": "./dist/setup/production.js", // Zero dependencies
"default": "./dist/index.js" // Full implementation
}
}
}

The "production" condition is custom (not a Node.js built-in). Bundlers must be configured to recognize it:

esbuild:

Terminal window
esbuild --bundle --splitting --outdir=dist --define:process.env.NODE_ENV='"production"' --conditions=production

webpack:

webpack.config.js
module.exports = {
mode: "production",
resolve: {
conditionNames: ["production", "import", "require"],
},
};

Vite:

vite.config.js
export default {
resolve: {
conditions: ["production"],
},
};

rollup:

import resolve from "@rollup/plugin-node-resolve";
export default {
plugins: [
resolve({
exportConditions: ["production"],
}),
],
};

Trade-off:

  • ✅ Smallest possible build artifacts (~298kb total vs ~27kb + ~242kb)
  • ⚠️ GLOBAL configuration affects all dependencies
  • ⚠️ May break other packages using conditional exports
  • ⚠️ Requires auditing dependency tree

When to use conditional exports:

  • Build artifact size is critical (containers, CI/CD storage)
  • Disk space constrained environments
  • You’ve audited your dependency tree
  • You understand the global nature of --conditions

Default recommendation: Use code splitting (zero config). Only use conditional exports if build artifact size is critical and you understand the trade-offs

Verify bundled Express app with code splitting:

Terminal window
# Build production bundle with code splitting (default approach)
NODE_ENV=production npm run build
# Entry point should be small
ls -lh dist/server.js
# Search for MSW code (should not be in entry point)
grep -rE '(setupWorker|startWorker|HttpResponse\.json)' dist/server.js
# Should output nothing
# Verify impl chunk exists but is never loaded
ls -lh dist/impl-*.js # Chunk exists on disk
NODE_ENV=production node dist/server.js &
PID=$!
lsof -p $PID | grep 'impl-.*\.js' # Not loaded into memory
kill $PID

Example verification script (code splitting):

{
"scripts": {
"build:production": "esbuild src/server.ts --bundle --splitting --outdir=dist --platform=node --format=esm --external:express --define:process.env.NODE_ENV='\"production\"' --minify",
"verify:treeshaking": "npm run build:production && ! grep -rE '(setupWorker|HttpResponse\\.json)' dist/server.js"
}
}

Example verification script (conditional exports):

{
"scripts": {
"build:production": "esbuild src/server.ts --bundle --splitting --outdir=dist --platform=node --format=esm --external:express --define:process.env.NODE_ENV='\"production\"' --minify --conditions=production",
"verify:minimal": "npm run build:production && ! grep -rE 'impl-.*\\.js' dist/"
}
}

Run: npm run verify:treeshaking (code splitting) or npm run verify:minimal (conditional exports)

For detailed bundler configuration examples, see the Express Adapter README - Production Tree-Shaking.

Next.js (Detailed):

Next.js has separate client and server bundles. Verify both:

Terminal window
# Build
NODE_ENV=production npm run build
# Check client bundles for MSW runtime functions
! find .next/static -name "*.js" -exec grep -E '(setupWorker|HttpResponse\.json)' {} \;
# Should output nothing (exit code 0)
# Check server bundles for MSW runtime functions
! find .next/server -name "*.js" -exec grep -E '(http\.(get|post|put|delete|patch)|HttpResponse\.json)' {} \;
# Should output nothing (exit code 0)

Vercel Deployment:

If deployed to Vercel, check production bundle size in deployment logs:

Build Output:
├ ƒ / 1.2 kB 123 B
├ ○ /404 2.1 kB 456 B
└ ƒ /api/products 450 B 78 B

Compare with pre-Scenarist deployment - sizes should be nearly identical.

Prevent tree-shaking regressions by adding checks to CI:

GitHub Actions Example:

name: Verify Production Safety
on:
pull_request:
paths:
- "package.json"
- "src/**"
jobs:
verify-bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Build production bundle
run: NODE_ENV=production npm run build
env:
NODE_ENV: production
- name: Verify no MSW runtime code in bundle
run: |
if grep -rE '(setupWorker|startWorker|http\.(get|post|put|delete|patch)|HttpResponse\.json)' dist/ .next/static/ build/ 2>/dev/null; then
echo "❌ ERROR: MSW runtime code found in production bundle!"
echo "Tree-shaking failed. Check NODE_ENV and bundler config."
exit 1
fi
echo "✅ Verified: No MSW runtime code in production bundle"
- name: Compare bundle sizes
run: |
# Store baseline size (first run) or compare
BUNDLE_SIZE=$(du -sb dist/ .next/ build/ 2>/dev/null | awk '{sum+=$1} END {print sum}')
echo "Bundle size: $BUNDLE_SIZE bytes"
# Add budget check if needed

npm script:

Add to package.json for manual verification:

{
"scripts": {
"verify:production": "NODE_ENV=production npm run build && ! grep -rE '(setupWorker|HttpResponse\\.json)' dist/ .next/static/ build/"
}
}

Run: npm run verify:production

✅ Passing verification:

  • Bundle analyzer shows NO scenarist or msw packages
  • grep search returns no matches (exit code 1)
  • Bundle size same as before adding Scenarist (±1KB)
  • Source maps show NO Scenarist source files
  • Production deployment works normally

❌ Failed verification (tree-shaking didn’t work):

  • Bundle contains scenarist or msw strings
  • Bundle size significantly larger than expected
  • createScenarist function visible in bundle
  • MSW handlers appear in production code

If verification fails, check these common issues:

1. NODE_ENV not set to ‘production’:

Terminal window
# ❌ Wrong - tree-shaking won't work
npm run build
# ✅ Correct - enables tree-shaking
NODE_ENV=production npm run build

2. Bundler not configured for tree-shaking:

Most bundlers enable tree-shaking by default in production mode. Verify config:

Webpack:

webpack.config.js
module.exports = {
mode: "production", // ← Required
optimization: {
usedExports: true, // ← Should be true (default in production)
sideEffects: true, // ← Should be true (default)
},
};

Vite:

// vite.config.js - tree-shaking automatic in production
export default defineConfig({
build: {
minify: "terser", // Or 'esbuild' - both tree-shake
},
});

3. Dynamic imports preventing tree-shaking:

// ❌ Wrong - prevents tree-shaking
const scenarist = require("@scenarist/express-adapter");
// ✅ Correct - enables tree-shaking
import { createScenarist } from "@scenarist/express-adapter";

4. package.json sideEffects field:

Check if your bundler respects sideEffects: false in Scenarist’s package.json:

Terminal window
cat node_modules/@scenarist/*/package.json | grep sideEffects
# Should show: "sideEffects": false

If this is missing, file an issue - this is a bug.

5. TypeScript compilation settings:

Ensure TypeScript preserves ES modules:

tsconfig.json
{
"compilerOptions": {
"module": "ES2020", // ← Or "ESNext", not "CommonJS"
"moduleResolution": "bundler" // ← Or "node16"
}
}

CommonJS modules prevent tree-shaking in most bundlers.

If tree-shaking still fails after checking the above:

  1. Check your bundler version - Update to latest version
  2. Review bundler logs - Look for warnings about side effects
  3. Minimal reproduction - Test in fresh project to isolate issue
  4. Open an issue - Report it with:
    • Bundler name and version
    • Framework name and version
    • Bundler config
    • Output of npm ls @scenarist/*

For additional help, see:

The return type ExpressScenarist<T> | undefined (or equivalent for your adapter) forces you to handle the production case:

// TypeScript enforces null checks
if (!scenarist) {
// Production mode - scenarist is undefined
return;
}
// Development/test mode - scenarist is defined
scenarist.start();

This prevents accidentally calling test methods in production code.

import { createScenarist } from "@scenarist/express-adapter";
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// Type-safe null check required
if (scenarist) {
scenarist.start(); // Only runs in development/test
}

Both Next.js adapters use the synchronous pattern with conditional exports for production safety:

lib/scenarist.ts
import { createScenarist } from "@scenarist/nextjs-adapter/app";
// or: import { createScenarist } from '@scenarist/nextjs-adapter/pages';
// Synchronous - no async/await needed
export const scenarist = createScenarist({
enabled: true,
scenarios,
});
// MSW auto-starts in development/test (when scenarist is defined)
if (typeof window === "undefined" && scenarist) {
scenarist.start();
}

Production behavior:

  • Conditional exports resolve to production.js which returns undefined immediately
  • Zero imports in production.js guarantees tree-shaking
  • Zero configuration: Works automatically based on NODE_ENV

Safe Helper Functions:

To avoid scenarist?.method() ?? fallback patterns everywhere, both adapters export safe helper functions:

// App Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/app";
export async function GET(request: Request) {
const response = await fetch("http://localhost:3001/products", {
headers: {
...getScenaristHeaders(request), // Always safe, no guards needed
"x-user-tier": "premium",
},
});
}
// App Router Server Component
import { headers } from "next/headers";
import { getScenaristHeadersFromReadonlyHeaders } from "@scenarist/nextjs-adapter/app";
export default async function ProductsPage() {
const headersList = await headers();
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeadersFromReadonlyHeaders(headersList),
});
}
// Pages Router API Route
import { getScenaristHeaders } from "@scenarist/nextjs-adapter/pages";
export default async function handler(req, res) {
const response = await fetch("http://localhost:3001/products", {
headers: getScenaristHeaders(req),
});
}

Available helpers:

  • App Router:
    • getScenaristHeaders(request) - Extract Scenarist headers from Request
    • getScenaristHeadersFromReadonlyHeaders(headers) - Extract Scenarist headers from ReadonlyHeaders
    • getScenaristTestId(request) - Extract test ID string from Request
    • getScenaristTestIdFromReadonlyHeaders(headers) - Extract test ID string from ReadonlyHeaders
  • Pages Router:
    • getScenaristHeaders(req) - Extract Scenarist headers from IncomingMessage

These helpers access global singletons and return safe defaults in production (empty objects for headers, 'default-test' for test IDs), eliminating the need for manual undefined checks.

Does Scenarist run in production if I forget NODE_ENV?

Section titled “Does Scenarist run in production if I forget NODE_ENV?”

No. Even without NODE_ENV=production, Scenarist only activates when explicitly started. The scenarist?.start() call is typically in development server setup, not production runtime.

However, you should always set NODE_ENV=production for:

  • Optimal performance
  • Correct framework behavior
  • Automatic tree-shaking

CI/CD environments should use:

  • NODE_ENV=test for running tests
  • NODE_ENV=production for building production bundles

Scenarist works in both modes:

  • Test mode: Full functionality for scenario-based tests
  • Production mode: Returns undefined, enabling tree-shaking

Can I verify tree-shaking before deploying?

Section titled “Can I verify tree-shaking before deploying?”

Yes! See the Production Tree-Shaking Verification section for:

  • Step-by-step verification with lsof (proves chunk never loads)
  • Bundle size analysis
  • Build artifact inspection
  • Framework-specific verification commands
  • Red flags and troubleshooting

If your bundler doesn’t tree-shake Scenarist:

  1. Check NODE_ENV: Ensure NODE_ENV=production during build
  2. Check bundler config: Verify tree-shaking is enabled (it usually is by default)
  3. Check sideEffects: Scenarist marks itself as side-effect-free in package.json
  4. File an issue: If tree-shaking still fails, open an issue with your bundler details
  1. Always use NODE_ENV=production for production builds

    Terminal window
    NODE_ENV=production npm run build
  2. Add null checks where you use Scenarist

    if (scenarist) {
    scenarist.start();
    }
  3. Verify bundle size in CI/CD

    - name: Check bundle size
    run: npm run build && npm run analyze
  4. Monitor production bundles

    • Set up bundle size budgets
    • Alert on unexpected size increases
    • Review bundle composition regularly

Beyond production safety, Scenarist provides cryptographic verification of package authenticity:

  • npm Provenance: Packages include npm provenance attestations
  • GitHub Attestations: Build provenance attestations verifiable via GitHub CLI
  • SBOM: Software Bill of Materials signed with Sigstore

Verify any Scenarist package was built from this repository:

Terminal window
# Download and verify a package
npm pack @scenarist/core
gh attestation verify scenarist-core-*.tgz -R citypaul/scenarist

View all attestations at github.com/citypaul/scenarist/attestations.

For complete security documentation, see SECURITY.md.

Scenarist is designed for production safety:

AspectGuarantee
Production runtimeZero test code execution
Bundle sizeZero Scenarist code after tree-shaking
ConfigurationNo environment-specific config needed
Type safetyTypeScript enforces null checks
PerformanceNo runtime overhead in production

The bottom line: Scenarist’s production wrapper + tree-shaking = zero production impact.