Skip to content
All posts
Open SourceToolingJavaScript

Removing console.log is harder than it looks

Most tools delete the whole statement — and quietly delete your code with it. Notes from building strip-console, a console stripper that understands scope, purity and source maps.

Anurag Kumar

· 4 min read

Shipping console.log to production is a habit every team keeps trying to break. The usual fix is one line of build config — drop: ['console'] in esbuild, drop_console in Terser, a Babel plugin. I used those for years, until I looked closely at what they do to this:

console.log(i++);

Every one of them deletes the statement. The log goes away, and so does i++. The bug that follows shows up only in production, where you have no logs left to find it with.

That is why I wrote strip-console: a stripper that treats the argument list as code, not as text to throw away.

Arguments can do work

The rule is simple to state and fiddly to implement: remove the call, keep anything inside it that could have an effect.

Input Output
console.log('ready'); (line removed)
console.log(i++); i++;
console.log(await load()); await load();
const done = () => console.log('done'); const done = () => void 0;

That last row matters more than it looks. An arrow function with an expression body still has to return something; delete the body and you get a syntax error. A concise arrow becomes void 0, and if (debug) console.log(state) becomes if (debug) ; — ugly, but correct, and the minifier cleans it up afterwards.

Deciding what is safe to drop is a purity check over the AST. Literals, identifiers, template literals with pure parts, pure operators: all droppable. Calls, assignments, await, delete, spread: kept. Anything the checker does not recognise counts as impure, so the failure mode is keeping too much code rather than deleting something that mattered.

console is not always console

The other half of the problem is knowing whether the thing you are about to delete is really the global console.

function render(console) {
  console.log('this one is a parameter — keep it');
}

const { log } = console;
log('this one is the real thing — remove it');

Handling that needs a scope chain: every function, block, catch clause and loop gets a scope, declarations are recorded (including destructuring patterns and hoisted vars), and a name is only treated as the global console when nothing in the chain shadows it. The same table tracks aliases, so const { log } = console marks log as console.log and removes it later. window.console.info, globalThis.console.debug and console?.log?.() resolve to the same thing.

Most tools get the parameter case right and the alias case wrong, because a scope chain is more work than a pattern match.

Edit, don't reprint

Formatters and minifiers parse your file and print it back out. That is a lot of churn for removing a few calls, and it means every line of a file can change even if nothing about it did.

strip-console parses with oxc and edits the source with magic-string, splicing out only the ranges it removes. Everything else stays byte for byte identical, and you get a source map that still points at your original code. On a corpus of 881 files (28.8 MB) it ran in 3.1 s, against 3.7 s for esbuild's drop, 12.5 s for the Babel plugin and 14.8 s for Terser.

Builds that don't take plugins

The library ships plugins for Vite, Rollup, Rolldown, webpack, Rspack and esbuild. But two of the toolchains I work with every day don't accept bundler plugins at all: Next.js builds with Turbopack, and the Angular CLI's esbuild builder keeps its plugin list to itself.

So the CLI works on output instead of on modules:

npx strip-console dist/           # rewrite built files in place, updating .map files
npx strip-console audit dist/     # list what's left; exit 1 if anything is found

That turned out to be the feature I use most. audit in CI is a guard rather than a transform — it fails the build with one line per leftover call:

dist/assets/index-B7x2.js:1:2041  console.log
Found 1 console call in 1 file.

You can run it on a bundle you didn't build, which no plugin can do.

What it cannot do

Static analysis can't see console[level](msg) when level is only known at runtime. For that there's an opt-in runtime guard that stubs the listed methods at the top of the entry chunk.

And the obvious caveat, stated plainly in the README: removing console calls hides output from DevTools. It is not a security measure. Anything in your bundle or on the wire is still readable.

What I took from it

Writing a tool to do one small job properly is a good way to learn how much hides inside "just remove the logs". Scope, purity, concise arrow bodies, optional chaining, source maps, the file that fails to parse — each one is a decision about what should happen when the easy answer is wrong.

The repo has the full option list, framework recipes and benchmarks: github.com/i-m-anurag/strip-console.

Let’s talk

Have a hard problem? Let’s build the answer.

I’m always up for conversations about AI products, platform architecture and leading engineering teams.