Configuring Terser pure_funcs for Dead-Code Removal

Symptom: opening the Network tab on the production build shows the main bundle 8-15 KB heavier than expected, and a quick grep -c "console\." dist/assets/index-*.js against the minified output returns dozens of matches. DevTools console on the live site prints internal state on every render — [Auth] token refreshed, [Cache] hit for /api/reports, assertInvariant: expected array, got undefined — none of which should be visible to end users, and all of which cost real bytes and real parse time on every page load.

$ grep -o 'console\.[a-z]*(' dist/assets/index-a1b2c3.js | sort | uniq -c
     47 console.debug(
     23 console.info(
     12 console.log(
      6 console.warn(
      3 console.error(

Ninety-one call sites total, most of them dev-only diagnostic output that a correctly configured minifier should have stripped before the code ever reached the bundle.


Root Cause: Terser’s Conservative Default Behavior

This is a specific diagnostic scenario within the broader technique of eliminating dead code with modern build tools. Terser, like any minifier, only removes code it can prove has no observable effect on the program. A bare function call such as console.debug('cache miss') might, for all Terser’s static analysis knows, be essential — perhaps it writes to a buffer that a later console.table() reads, or perhaps a monkey-patched console.debug in some other module depends on being invoked for side effects unrelated to logging. Without an explicit instruction, Terser’s default compress options never assume a function call is safe to eliminate purely because its name looks like a logging call.

This conservative default is correct in general — it is what keeps Terser from breaking programs — but it means teams get zero automatic removal of console.* calls or custom assertion helpers unless they opt in explicitly through pure_funcs, drop_console, or inline /*#__PURE__*/ annotations. The gap is easy to miss because development builds show the same log output either way, so the only place the problem becomes visible is a production bundle audit or a live-site DevTools console — often long after the calls were added.

A related but distinct source of avoidable production bytes is barrel-file re-export overhead, covered in replacing barrel exports with direct module imports — that page addresses import-graph structure, while this one addresses call-site elimination during minification. Both compound with the sideEffects declarations that control the bundler’s earlier tree-shaking pass, since minification runs as a final pass after the bundler has already decided which modules survive.

It helps to separate two categories of dev-only code that end up in the same bundle for different reasons. The first category — console.debug, console.info, verbose tracing — is code that runs identically in every environment; the only difference between dev and prod is that nobody wants to see the output in prod. pure_funcs and drop_console target exactly this category, because the call itself is genuinely safe to remove once you tell Terser so. The second category — assertion helpers like assertInvariant or invariant() that throw on a broken precondition — is trickier, because removing the call also removes a runtime safety net, not just noisy output. For assertions, pure_funcs is only appropriate if the assertion’s failure mode in production is “silently continue,” which is true for most defensive console.assert-style checks but not for checks that guard against genuinely unsafe states. Read the call site’s failure behavior before adding its name to the list, not just its logging-adjacent name.

Measuring the Real-World Impact

On a mid-size single-page application with routine console.debug/console.info instrumentation scattered across data-fetching hooks, state management middleware, and component lifecycle boundaries, this configuration change typically removes 4-8% of total minified bundle size — smaller than a structural change like barrel file elimination, but free once configured, since no source files need to change. The gain scales with how verbose the codebase’s logging habits are: teams using a structured logger with many call sites see a larger reduction than teams using only occasional console.log debugging left over from development.

Exact Minimal Configuration Fix

Webpack 5 — TerserPlugin with pure_funcs and drop_console

// webpack.config.js — Webpack 5
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: false,              // keep console.error reachable for production error tracking
            pure_funcs: [
              'console.debug',
              'console.info',
              'console.log',
              'logger.trace',
              'assertInvariant'                // custom dev-only assertion helper
            ]
          }
        }
      })
    ]
  }
};

Setting drop_console: false while listing specific method names in pure_funcs keeps console.error and console.warn intact for production error monitoring, while still eliminating the noisy diagnostic calls. If no console output should ever reach production, replace both options with a single drop_console: true and drop the console entries from pure_funcs.

Vite 5+ — switching from esbuild to Terser for pure_funcs support

Vite’s default minifier is esbuild, which is faster but does not support pure_funcs or named-function purity lists. To use this feature under Vite, switch the build minifier to Terser explicitly:

// vite.config.js — Vite 5+
export default {
  build: {
    minify: 'terser',                          // esbuild default does not support pure_funcs
    terserOptions: {
      compress: {
        drop_console: false,
        pure_funcs: [
          'console.debug',
          'console.info',
          'console.log',
          'logger.trace',
          'assertInvariant'
        ]
      }
    }
  }
};

This trades a few seconds of extra build time for pure_funcs support — on most application-scale bundles the difference is under 5 seconds of total build time, a reasonable cost for a build-time-only setting with no runtime cost.

Marking ambiguous calls with /*#__PURE__*/

Named-function pure_funcs entries only work for calls Terser can resolve to a known identifier. A factory function returned from another call, or a method call through a variable of unknown type, needs an explicit annotation instead:

// analytics.js — annotate a call whose purity Terser cannot infer from the identifier alone
const debugPayload = /*#__PURE__*/ buildDebugSnapshot(state);

// Without the annotation, Terser keeps buildDebugSnapshot(state) even though
// debugPayload is never read below this line in the production code path.
if (process.env.NODE_ENV !== 'production') {
  console.log(debugPayload);
}

Place the comment directly before the call expression, not before the assignment — Terser only recognizes the annotation when it immediately precedes the parenthesized call it applies to.

Step-by-Step Verification Checklist

  1. Grep the pre-change bundle for a baseline call count.

    grep -o 'console\.[a-z]*(' dist/assets/index-*.js | sort | uniq -c
  2. Apply the terserOptions change above and rebuild with npx webpack --mode=production or npx vite build.

  3. Re-run the same grep against the new bundle. Every method name listed in pure_funcs should show a zero count, and console.error/console.warn counts should be unchanged if you kept drop_console: false.

  4. Confirm the custom assertion helper is gone, not just console calls:

    grep -c "assertInvariant" dist/assets/index-*.js
    # Expected: 0
  5. Diff the total bundle size against the pre-change baseline using size-limit --compare or a direct du -b comparison — a codebase with verbose logging typically sees a 3-8% reduction from this change alone.

  6. Load the production build in a browser and open DevTools. Confirm no debug/info/log output appears on normal interaction, but any intentionally retained console.error calls still surface — this catches the case where pure_funcs was too broad and silently removed an error path you meant to keep.

  7. Run the full test suite. A test that asserts on console.log output (common in older Jest suites using jest.spyOn(console, 'log')) will fail once the call is stripped from production code — this is expected and the assertion should move to a development-only code path or be removed.

Edge Cases and Gotchas

Gotcha 1 — pure_funcs requires the exact call expression Terser sees, not the source-level name

If a build step renames or re-exports a logging function before minification (for example, a bundler alias mapping import { log as debugLog }), the call site in the minified AST may resolve to debugLog(...), not log(...). Fix: list the name as it appears in the call expression after any aliasing, or run webpack --stats-modules / inspect the pre-minification output to confirm the exact identifier Terser will see.

Gotcha 2 — Chained or destructured logger methods bypass the purity list

A pattern like const { debug } = logger; debug('message') calls a bare debug identifier, not logger.debug, so a pure_funcs entry of 'logger.debug' will not match. Fix: either list the destructured bare name ('debug' — risky if it collides with unrelated functions named debug) or refactor the call site to always use the qualified logger.debug(...) form, which is also better practice for readability.

Gotcha 3 — Side effects hiding inside a “pure” function’s arguments survive removal

pure_funcs removing logger.debug(computeExpensiveSnapshot()) still evaluates and discards computeExpensiveSnapshot()'s return value — Terser removes the outer call but generally preserves argument evaluation if the arguments themselves might have side effects, unless they too are provably pure. Diagnostic signal: a CPU profile shows an “unused” diagnostic function still executing in production despite the surrounding log call disappearing from the bundle. Fix: guard expensive diagnostic computation behind an explicit if (process.env.NODE_ENV !== 'production') check at the call site, rather than relying on the minifier to prove the entire expression tree is dead.

Gotcha 4 — Source maps make removed calls reappear during debugging, causing false alarms

After deploying a build with pure_funcs removing diagnostic calls, a developer stepping through a production source map in DevTools may see the original, unminified source — including the removed console.debug lines — because source maps preserve the pre-minification source text for readability even though the corresponding bytes are gone from the executable bundle. Diagnostic signal: a teammate reports “the log statement is still in the code” while looking at the Sources panel, but a grep of the actual served .js file shows zero matches. Fix: clarify that source maps reflect original source, not what executes; verify removal against the compiled bundle file directly, never against the source-mapped view in DevTools.


FAQ

What is the difference between drop_console and pure_funcs for removing console calls?

drop_console unconditionally removes every console.* call, including console.error and console.warn, regardless of context. pure_funcs is more selective: it only removes a call when its return value is discarded, and it lets you target specific method names or custom logging functions individually, so you can keep console.error while dropping console.debug and console.info.

Why doesn’t Terser remove my custom logger calls automatically?

Terser only removes a function call if it can prove the call has no side effects. A custom logger might write to a network buffer, mutate module-level state, or format a timestamp — Terser cannot verify any of that by static analysis, so it conservatively keeps the call. Listing the function’s fully qualified name in pure_funcs tells Terser to trust that the call is safe to drop when unused.

What does the /#PURE/ annotation do?

It is a magic comment placed immediately before a function call that tells Terser and other minifiers (esbuild, Rollup, SWC) that the call produces no observable side effects. If the call’s return value is never used, the minifier removes the entire expression. It is most useful on factory functions or getters whose purity cannot be inferred from the function name alone.

Is it safe to use drop_console in every production build?

Only if no production code path depends on console output for error reporting. Many teams keep console.error active in production (routed to an error-tracking service) while dropping console.debug and console.info. Use pure_funcs with an explicit method list instead of the blanket drop_console option when any console method needs to survive into production.