跪拜 Guibai
← Back to the summary

Deleting a 'Useless' console.log Broke Production: Lazy Getters and Hidden Side Effects

Deleting a "Useless" console.log Crashed Production Directly!

Deleting a "Useless" console.log Crashed Production Directly!

Hello everyone 😃.

Recently we were preparing to ship a new version, and while we were at it, we cleaned up a bunch of console.log calls scattered around the project. This is routine hygiene in most teams: production shouldn't have debug logs, everybody knows that.

The colleague in charge was quick: a global search for console.log, confirming and deleting each one, done in half an hour. CI passed, test environment passed, no anomalies. We shipped.

20 minutes after the release, the alert group started going off.

On a core product detail page, some users saw the price display as undefined. Not all users, and not all products: only products with tiered pricing rules were affected.

After rolling back, we started investigating. Pulling the git diff, the release changed 47 files, of which 38 were deletions of console.log. The line we finally pinpointed looked like this:

// The deleted line
console.log('price config:', product.priceConfig);

Just that one line. After deleting it, product.priceConfig became undefined in the subsequent rendering logic.

A single console.log turned out to be a precondition for a piece of business logic to run!!!

It sounds absurd, but after I pulled apart the implementation of the product object, everything made sense.


Why console.log Is Not a Pure Read?

Most people's mental model of console.log(obj) is: it reads the value, prints it to the console, and affects nothing else. A pure read operation; deleting it should have no impact on program behavior.

That understanding is wrong!

In JavaScript, reading a property itself can trigger code execution. console.log(obj.foo) at the engine level does this👇:

screenshot-20261027-155247.png

Every step in this chain can produce side effects. And console.log happens to be the trigger that fires the whole chain.


What Actually Happened in Our Project

Back to the incident. The product object is not an ordinary object; it's a reactive object constructed from a state management layer. The simplified core logic looks like this:

class ProductModel {
  private _rawData: RawProductData;
  private _priceConfig: PriceConfig | null = null;

  constructor(raw: RawProductData) {
    this._rawData = raw;
  }

  // Tiered pricing config is lazy-loaded — parsed from raw data on first access
  get priceConfig(): PriceConfig {
    if (this._priceConfig === null) {
      this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
    }
    return this._priceConfig;
  }

  private parsePriceRules(rules: RawPriceRule[]): PriceConfig {
    // A fairly heavy parsing logic: validate rule legality, sort, merge intervals...
    return {
      tiers: rules
        .filter(r => r.minQty > 0 && r.price > 0)
        .sort((a, b) => a.minQty - b.minQty)
        .map(r => ({ minQuantity: r.minQty, unitPrice: r.price })),
      currency: this._rawData.currency ?? 'CNY',
    };
  }
}

See it? The priceConfig above is a getter, and a getter with lazy initialization. It only parses the raw data and caches the result when first accessed. Subsequent accesses return the cached value directly.

screenshot-20261027-161111.png

The deleted console.log('price config:', product.priceConfig) was written in the component's setup function, positioned before the rendering logic. It looked like just printing debug info, but it was actually the first place that triggered the priceConfig getter — it did the lazy initialization.

After deletion, the priceConfig getter was never triggered before the rendering stage. And in the rendering logic, there was a piece of code written like this:

// Problem code in rendering logic
const displayPrice = product['_priceConfig']?.tiers[0]?.unitPrice;

This code didn't access priceConfig through the getter; it directly accessed the private property _priceConfig — because at some point in history, bracket syntax was used to bypass TypeScript access restrictions. And _priceConfig's initial value is null; it only gets assigned after the getter is triggered.

console.log gone → getter never triggered → _priceConfig stays null → page shows undefined.

A useless debug statement was actually the starting point of the entire initialization chain.


Let's Discuss 3 Common console.log Side-Effect Patterns

After reviewing this incident, I went back and examined several other "meaningful" console.log calls in the project, and found that this kind of hidden side effect is far more common than I thought.

Side Effects in Proxy Interceptors

If you use Vue 3 or any Proxy-based reactive system, console.log-ing a reactive object triggers the Proxy's get interceptor. And in Vue's reactive system, triggering get means dependency collection.

const state = reactive({ count: 0 });

// This console.log looks normal
// But it triggers the getter of state.count
// If this line executes in the context of some watchEffect or computed,
// it registers count as a dependency of that effect
console.log('current state:', state.count);

After deleting this console.log, some computed or watchEffect may no longer track changes to count — because its dependency collection phase lost one access to count. Result: count changes, but the UI doesn't update.

This kind of bug is extremely subtle, because you'd never suspect that deleting a console.log caused it 🤷‍♂️.

Implicit Invocation of toString and Symbol.toPrimitive

When console.log serializes output, it can trigger implicit type coercion for certain objects:

class TrackingToken {
  private _accessed = false;

  // When this object is converted to a string (e.g., printed by console.log)
  // mark it as accessed
  toString(): string {
    this._accessed = true;
    return `[Token accessed=${this._accessed}]`;
  }

  get isAccessed(): boolean {
    return this._accessed;
  }
}

const token = new TrackingToken();

// This line triggers toString(), setting _accessed to true
console.log('token:', token);

// Subsequent logic depends on the value of isAccessed
if (token.isAccessed) {
  // This branch is entered because console.log triggered toString
  // After deleting console.log, this branch will never execute
  reportTokenUsage(token);
}

This pattern really exists in scenarios like tracking, tracing, and permission checks.

Reference Retention Causing WeakRef Behavior Changes

This one is even more subtle. console.log keeps a strong reference to the passed object (at least until the console is cleared). If your code relies on WeakRef or FinalizationRegistry for resource cleanup, console.log might keep an object that should be collected alive:

let ref: WeakRef<HeavyResource>;

function initResource() {
  const resource = new HeavyResource();
  ref = new WeakRef(resource);
  
  // This console.log holds a strong reference to resource
  // causing resource not to be GC'd
  // WeakRef.deref() always returns the object
  console.log('resource created:', resource);
}

function checkResource() {
  const resource = ref.deref();
  if (resource) {
    // With console.log, resource stays alive, so this always runs
    // After deleting console.log, resource may be GC'd, so this won't run
    resource.doWork();
  }
}

In production you don't open the console, so console.log's reference retention differs from the development environment. But if your code happens to depend on that accidental reference retention at a particular timing, deleting it will break things.


Don't Do Global Search and Delete!

After this incident, our team adjusted its strategy for dealing with console.log.

Use Build Tools to Strip Automatically, Not Manually Delete

The problem with manually deleting console.log is that you must understand the context of every single line to determine if it's safe. That's unrealistic in a project with hundreds of files.

The right approach is to strip it automatically during the build phase, keeping console.log in the source (so it's still available in development) but not in the bundle:

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  esbuild: {
    // Automatically remove console and debugger in production builds
    drop: process.env.NODE_ENV === 'production' 
      ? ['console', 'debugger'] 
      : [],
  },
});

But note: esbuild's drop: ['console'] does syntax-level removal; it deletes the entire console.log(expr) statement. If expr itself has side effects (like the getter trigger we encountered), those side effects vanish too.

So this automatic stripping essentially faces the same problem as manual deletion — it assumes the argument expression of console.log has no side effects.

How to Fix This Thoroughly?

The root cause isn't console.log, but:

  1. A lazy-initialized getter being implicitly triggered, without an explicit initialization call
  2. Business code bypassing the getter and directly accessing the private property

The fix is not to avoid deleting console.log:

// Lazy getter + direct private property access
class ProductModel {
  private _priceConfig: PriceConfig | null = null;
  
  get priceConfig(): PriceConfig {
    if (this._priceConfig === null) {
      this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
    }
    return this._priceConfig;
  }
}

// Rendering directly touches private property (bypasses getter)
const price = product['_priceConfig']?.tiers[0]?.unitPrice;

After refactoring👇:

// Explicit initialization + ban direct private property access
class ProductModel {
  private _priceConfig: PriceConfig;

  constructor(raw: RawProductData) {
    this._rawData = raw;
    // Complete initialization at construction time, no lazy loading
    this._priceConfig = this.parsePriceRules(raw.priceRules);
  }

  get priceConfig(): PriceConfig {
    return this._priceConfig;
  }
}

// Rendering accesses via public getter
const price = product.priceConfig.tiers[0]?.unitPrice;

If lazy loading is truly necessary for performance (e.g., parsing is expensive and most scenarios don't need the value), then use an explicit init() method instead of implicit first-access triggering:

class ProductModel {
  private _priceConfig: PriceConfig | null = null;

  // Explicit call, clear semantics: whoever calls init owns initialization
  initPriceConfig(): void {
    if (this._priceConfig === null) {
      this._priceConfig = this.parsePriceRules(this._rawData.priceRules);
    }
  }

  get priceConfig(): PriceConfig {
    if (this._priceConfig === null) {
      throw new Error(
        'priceConfig not initialized, please call initPriceConfig() first'
      );
    }
    return this._priceConfig;
  }
}

Accessing before initialization throws an exception directly. Use explicit failure instead of implicit silence, letting the problem surface at development time.


You Can Add an ESLint Rule to Your Project

If you want to prevent such issues at the frontend engineering level, you can configure an ESLint rule to prohibit assignments (side effects) inside getters:

// .eslintrc.js
module.exports = {
  rules: {
    // Prohibit side effects in getters
    'no-setter-return': 'error',
    // Combined with @typescript-eslint rules to restrict direct private property access
    '@typescript-eslint/no-unsafe-member-access': 'warn',
  },
};

Rules can't cover every scenario, but at least they foster awareness in the team: getters should be pure computations, without assignments, network requests, event triggers, or other side effects. If you need side effects, write them as explicit method calls.


After going through this incident, my understanding of the act of "reading" in JavaScript deepened.

In many languages, reading a property is just reading a memory address, with no other effect. But in JavaScript, because of getter, Proxy, Symbol.toPrimitive, valueOf, toString and other mechanisms, every property access can be a function call, and every function call can change program state.

So next time you see a redundant console.log in your code, think for three seconds before deleting it: does its argument expression contain a getter? Is the object a Proxy? Does it have a custom toString? If you're not sure, don't delete it manually — leave it to the build tool, and at the same time check whether that code has implicit side-effect dependencies.

What really needs fixing isn't console.log, but the code design that hides side effects inside property access.

What do you think?

Suggestion (3).gif


Liked my article? Feel free to follow my WeChat public account: 【前端技术官】.

Main topics: Frontend Architecture · AI Programming · Career Insights · Developer Growth

前端技术官-ErpanOmer

Scan to follow 👆

Irregular updates, no spam, only genuinely useful content.

Comments

Top 5 of 9 from juejin.cn, machine-translated. The original thread is authoritative.

阿懒

So if it runs, don't touch it [black face]

ErpanOmer

Incisive!

Gumayusi

If my colleague wrote it like this, I'd lose it [I need some peace and quiet]

ErpanOmer

What a colleague you have!

happyfire

Can AI review before committing solve this?

ErpanOmer

Not necessarily; these are runtime errors.

中国首富

Great article.

ErpanOmer

[heart][heart]

小咔拉迷

It's not about the log; it's about the colleague.