Removing Development-Only Code From Production Bundles

A production bundle search that finds anything here is a problem:

$ grep -c "mockServiceWorker\|__DEV_PANEL__\|fixtures/users" dist/assets/*.js
dist/assets/index-4f2a91.js:14

Fourteen matches means the mock server, the debug panel, and the test fixtures all shipped. Users download a development environment they cannot see, evaluate it during startup, and in the worst case expose internal fixture data in a file anyone can read.

The general mechanism is covered in eliminating dead code with modern build tools. What makes development-only code a distinct case is that it is conditionally dead: the code is genuinely used in one environment and genuinely unreachable in the other, and whether the optimiser can prove that depends entirely on how the condition is written.

Root cause: a runtime check is not a build-time fact

Consider two guards that behave identically at runtime:

// A β€” opaque to the optimiser: the property could hold anything.
if (getConfig().environment !== 'production') {
  startMockServer();
}

// B β€” folded at build time: the comparison becomes `false`.
if (process.env.NODE_ENV !== 'production') {
  startMockServer();
}

In form A, nothing lets the bundler conclude the branch cannot run, so startMockServer and everything it imports must be retained. In form B, a define replacement substitutes the literal "production" before optimisation, the comparison folds, the branch becomes unreachable, and the mock server’s entire dependency subtree loses its last reference.

The size difference is rarely in the guarded lines. It is in what they reach.

Why the Guard's Shape Decides the Payload An opaque condition keeps the guarded branch and its whole dependency subtree; a build-time constant folds the condition, making the branch unreachable and its dependencies unreferenced. Opaque condition β€” 62 KB retained if (getConfig().environment !== 'production') startMockServer() β€” kept mock server 38 KB fixtures + faker 24 KB the branch might run, so the subtree stays Folded constant β€” 0 KB retained if ("production" !== 'production') β†’ if (false) branch removed entirely mock server unreferenced fixtures + faker unreferenced nothing references the subtree, so it is dropped Same runtime behaviour; only one of them is provable at build time

Fix 1: define the flags at build time

// vite.config.js β€” Vite 5+
export default {
  define: {
    // Literal substitution before optimisation: the comparison folds.
    __DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
    __ENABLE_MOCKS__: JSON.stringify(process.env.ENABLE_MOCKS === 'true'),
  },
};
// webpack.config.js β€” Webpack 5
const webpack = require('webpack');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      __DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
      __ENABLE_MOCKS__: JSON.stringify(process.env.ENABLE_MOCKS === 'true'),
    }),
  ],
};

The flag must be referenced directly in the condition. Assigning it to a variable first, or reading it through an object, reintroduces the opacity the define was meant to remove:

// Defeats the fold: the optimiser sees a variable, not a literal.
const isDev = __DEV__;
if (isDev) { /* … */ }

// Folds cleanly.
if (__DEV__) { /* … */ }

Fix 2: put heavy dev tooling behind a dynamic import

Even a folded branch cannot help if the module is imported statically at the top of the file β€” the static import is evaluated regardless of any condition. Move the import inside the guard.

The Import Position Decides the Outcome A top-level static import is evaluated whatever the guard says; an import inside the guarded branch becomes unreachable when the guard folds to false. Static import at the top β€” kept import { startMockServer } from './dev/mock' if (false) { startMockServer() } branch removed, module retained 62 KB still ships Dynamic import inside the guard β€” dropped if (false) { await import('./dev/mock') } nothing reaches the module 0 KB ships
// main.js β€” the dev dependency is reachable only from a dead branch
async function bootstrap() {
  if (__ENABLE_MOCKS__) {
    // Inside the guard: when the flag folds to false, this import is in
    // unreachable code, so nothing in the graph points at the package.
    const { startMockServer } = await import('./dev/mock-server');
    await startMockServer();
  }

  const { mount } = await import('./app');
  mount(document.getElementById('root'));
}

bootstrap();

This is the same reachability principle that governs component-level code splitting beyond routes: a module is included because something reaches it, and a dead branch reaches nothing.

Fix 3: strip verbose warning text

Libraries and application code both accumulate long developer-facing messages. In production they are payload with no reader.

// invariant.js β€” the check survives; the prose does not ship
export function invariant(condition, message) {
  if (condition) return;
  if (__DEV__) {
    throw new Error(`[app] ${message}`);   // full text in development
  }
  throw new Error('Invariant violation');  // stack trace still identifies it
}

Keep the throw. Stripping the error itself converts a loud failure into silent wrong behaviour, which is a far worse trade than a few hundred bytes.

What Leaves the Bundle Removing development-only code strips the mock server, fixtures, debug panel and verbose warning strings, leaving only application and vendor code. Production bundle composition (gzipped) Before app + vendor 148 KB mocks 38 fixtures panel msgs 218 KB After app + vendor 148 KB 148 KB β€” 32% smaller, identical behaviour 0 110 KB 220 KB The guarded lines are small; the dependencies they reach are not

Step-by-step verification

  1. Grep the production output. Search for marker strings unique to development code. Zero matches is the only acceptable result.

  2. Confirm the packages are gone. Check the attribution table for the mock and fixture packages; they should not appear at all β€” see attributing bundle bloat with source maps.

  3. Confirm the fold happened. In an unminified production build, the guard should read if (false) or be absent entirely, not reference the flag.

  4. Check the development build still works. The mocks and panel must still start with the development flags set; over-aggressive stripping that breaks development is a common overcorrection.

  5. Test the server build separately. Server bundles are configured independently and frequently keep development code long after the client build has dropped it.

  6. Add a CI assertion. Fail the build when a development marker appears in the production output, so a future static import cannot silently reintroduce it β€” the same guardrail approach as failing builds on bundle size regressions.

Edge cases and gotchas

Storybook and test utilities. A component importing a test utility for a default prop keeps the test package in production. Check for imports that cross from application code into test-only directories.

Feature flags misread as environment flags. A runtime feature flag is genuinely runtime and cannot fold. Keep the two concepts separate: build-time constants for environment, runtime values for features.

Source maps exposing what was stripped. Removing code from the bundle does not remove it from a published source map. Keep production maps private, as covered in uploading source maps to error monitoring without shipping them.

Dead code the minifier keeps for safety. A branch containing a function declaration hoisted and referenced elsewhere may be retained even when unreachable. Keep guarded blocks free of declarations used outside them.

FAQ

Why does checking an environment variable at runtime not remove the code?

Because a runtime read is opaque to the optimiser. If the condition is a property lookup that could hold any value, the branch might execute, so both sides must be kept. Removal requires the condition to be a literal at build time, which is what a define replacement produces: the comparison folds to false, the branch becomes unreachable, and everything it referenced becomes unreferenced.

How much does development-only code typically add?

More than expected, because the largest contributors are dependencies rather than the guarded code itself. A mock server, a component inspector, and a set of test fixtures together commonly account for 40 to 90 KB gzipped. The guarded lines are trivial; the packages they reach are not, and a static import to one of them keeps the whole package regardless of whether the branch can run.

Should development warnings be stripped too?

Yes for verbose developer-facing messages, no for anything a user or your monitoring depends on. Long explanatory strings are pure payload in production and are usually a meaningful share of a library’s bundled size. Actual error handling must survive: stripping the message but keeping the throw is the right trade, since the stack trace remains useful while the prose does not ship.