Your console.log Is Lying to You

blog.gaborkoos.com · 2026-06-29 · by Long Nguyen ·generic
console.log() feels like a direct window into your program's state. It isn't. Here are the ways the console misleads you, and why each one exists.

Your console.log Is Lying to You

Your console.log Is Lying to You

Sun Jun 28 2026 •

tutorials

javascript

debugging

devtools

... is lying to you

Open your browser DevTools and run this:

const user = { name: "Bob" }
console.log(user)
user.name = "Alice"

You would expect the log to show `{ name: "Bob" }`, the value at the time of the `console.log` call. The collapsed line is what you expect:

▶ Object { name: "Bob" }

But expand it, and you will see:

name: "Alice"

Oops. So what's going on? `console.log()` is the most-used debugging tool in JavaScript, but it can be subtly unreliable. Not because it is broken, but because it optimizes for speed and interactivity rather than for accuracy. It was built for fast exploration in a live, interactive environment, and those priorities come with tradeoffs that can genuinely mislead you during debugging. Over the next sections, we'll look at a few ways the console can mislead you - and, more importantly, why each one exists.

##

When you pass an object to `console.log()` in browser DevTools, the browser does not immediately serialize it into a string. Instead, it stores a live reference to that object and defers the actual rendering until you expand the entry. This is called lazy evaluation, and it is what caused the surprise.

The collapsed `▶ Object` you see is essentially a placeholder: the properties shown inside it are evaluated at the moment you click the arrow, not at the moment you called `console.log()`. By then, your code has already continued running. That means what you're seeing is not a frozen record of the object at the time of logging, but a live view into whatever the object happens to look like when DevTools renders it. In the example:

1. You log `{ name: "Bob" }` 2. DevTools stores a reference to the `user` object 3. The code continues executing 4. `user.name` is mutated to `"Alice"` 5. You expand the logged object later and see the current state

This behavior can feel unintuitive at first, because most developers mentally model `console.log()` as "print this value right now", but in browser DevTools, it is closer to "show me this object as it exists when I look at it".

This design is intentional: if DevTools were to eagerly serialize every object at log time, it would have to deeply traverse and copy potentially large object graphs on every log call. In complex applications, especially ones with frequent logging inside loops or render cycles, that overhead would be expensive in both memory and performance. By deferring evaluation, DevTools stays fast and interactive, even when working with large or constantly changing state.

But that optimization comes with a tradeoff: what you see in the console is not always what existed at the moment you logged it. Once you internalize that distinction (log time vs view time), a whole class of "weird bugs" starts making sense.

The same idea applies to the DOM, as a DOM node is still a JavaScript object, and DevTools may show you its current state when you expand it, not necessarily the state it had when you logged it. If a framework re-rendered the component, changed a class, removed an attribute, or replaced child nodes, an old `console.log(element)` can be misleading.

When the DOM state matters, snapshot the specific facts:

console.log({
  path: location.pathname,
  html: element.outerHTML,
  text: element.textContent,
  classes: [...element.classList],
  disabled: element.disabled,
})

The point is the same: preserve the evidence, not just a handle to something that may keep changing.

##

Promises change state over time, so a promise that was pending when you logged it may appear fulfilled when you inspect it later. This is related to a deeper async distinction: promises represent future results, not ownership of the work producing those results, which is why cancellation in JavaScript is harder than it looks.

const promise = fetch('https://jsonplaceholder.typicode.com/posts/1')
console.log(promise)

You see:

▶ Promise { <state>: "pending" }

Expand it after the network resolves:

<state>: "fulfilled"
<value>: Response { type: "cors", url: "https://jsonplaceholder.typicode.com/posts/1", redirected: false, … }
<prototype>: Promise.prototype { … }

The promise resolved after you logged it, but the console shows both the pending and resolved states depending on when you inspect it. The mechanism differs from the mutable object earlier: the promise genuinely settled from pending to fulfilled once, rather than being mutated repeatedly. The symptom, though, is the same: what you read depends on when you look, not on when you logged.

Unlike objects, DevTools isn't showing a live mutable object here: the promise really did transition from pending to fulfilled exactly once. But the experience is similar: what you learn from the console depends on when you inspect it, not merely when you logged it.

Heisenbug Effect: Logging Changes Reality

At some point, every JavaScript developer hits this: you have a bug, add one `console.log` to debug, rerun the flow, and the bug disappears. This may sound unbelievable at first, but logging can change timing enough to hide races and ordering bugs.

`console.log` is not free. It does formatting work, sends data to DevTools, and can trigger object inspection overhead. In hot paths, that extra work shifts scheduling just enough to change outcomes.

Common places where this matters:

  • Tight loops where tiny delays change interleaving
  • UI rendering paths where extra work affects frame timing
  • Event handlers where ordering between microtasks and macrotasks is fragile
  • Request dedupe and cache races where "first winner" behavior is timing-sensitive
  • This concept is not easy to demonstrate in a small snippet, and the result is deliberately engine-dependent, so you may see different outcomes in Chrome and Firefox. That variability is itself the point. Try this in a browser console:

    function run(label, withDebugWork) {
      let done = false;

    // Producer: flips done, but not immediately setTimeout(() => { done = true; }, 5);

    // Consumer: checks quickly const check = () => { console.log(label, done ? "OK" : "FAILED"); };

    if (withDebugWork) { console.log("debug path"); // simulate instrumentation overhead const t = performance.now(); while (performance.now() - t < 8) {} }

    setTimeout(check, 0); }

    run("no-debug", false); run("with-debug", true);

    The results are not reproducible, and that is exactly the point. Depending on the engine, your machine, and how busy it is, you might see both runs report `FAILED`, both report `OK`, or a split between them, and rerunning can change the answer. Sometimes the busy-wait in the debug path delays the consumer long enough for the producer's timer to fire first; sometimes it does not. The takeaway is not "the debug run passes." It is that the timing here is fragile enough that adding work in one path can flip the outcome at all. When a result depends on margins this thin, anything you add to observe it, including a single log, can change what you see.

    I once debugged a duplicate-request bug where two click handlers could fire before a shared `inFlight` flag was set. With no logs, both requests escaped. With a `console.log('click')` at the top of the handler, the second request consistently vanished: the log delayed execution enough that the first handler set `inFlight` before the second one checked it.

    How to debug without masking it? When you suspect a timing bug, prefer lower-impact techniques:

    1. Log less frequently and at boundaries, not inside hot loops. 2. Log primitives (`id`, `timestamp`, `state`) instead of large objects. 3. Use `performance.now()` markers to trace ordering explicitly. 4. Reproduce with and without logs to confirm observer effect. 5. Use breakpoints and the debugger timeline when possible.

    Most importantly: if one extra log changes behavior, you are not looking at a stable system, you are looking at a race.

    ##

    React adds another trap: the state value you log right after an update call is not the updated one.

    const [count, setCount] = useState(0)

    function handleClick() { setCount(count + 1) console.log('after setCount:', count) // stale value }

    `setCount` schedules an update, but that is only half the reason. The other half is closure capture: `count` is a `const` bound to this particular render, so nothing that happens later in the same handler can change it. Even without batching, the `count` you log here is the value from the render that created the handler.

    If you want to log the updated value, log from an effect tied to that state:

    useEffect(() => {
      console.log('count changed:', count)
    }, [count])

    The takeaway: the value you logged was never live to begin with. It was frozen into the handler when the render created it, so reading it after `setCount` tells you about that render, not your current state.

    The same stale-observation problem shows up in request-driven UI too: Your Debounce Is Lying to You covers stale responses and request lifecycle bugs, while Your Throttling Is Lying to You covers event timing bugs where the final observed state matters.

    ##

    Sometimes the misleading part is not the value, but where DevTools says it came from.

    In modern JavaScript apps, the code running in the browser is often not the code you wrote. It may have passed through TypeScript, Babel, minification, bundling, code splitting, JSX transforms, or framework compilers. Source maps try to connect the generated code back to your original files, but that mapping is only as good as the build pipeline that produced it.

    When source maps are stale, missing, incorrectly uploaded, or generated with low fidelity, console stack traces can point at the wrong file, the wrong line, or a line that only roughly corresponds to the generated code. This is especially confusing when debugging production builds, where minification and bundling can collapse many original modules into a small number of generated files.

    This does not mean DevTools is broken. It means it is doing a reverse lookup through a translation table.

    Common causes:

  • The browser is using an old cached bundle or source map.
  • The deployed JavaScript and deployed source maps are from different builds.
  • Minification collapsed or reordered code in ways the map only approximates.
  • Framework or compiler transforms moved your logic away from the line you wrote.
  • Production source maps were stripped, hidden, or uploaded only to an error tracker.
  • When the line number looks suspicious, verify the build artifact. Check whether the logged code exists in the compiled bundle, hard refresh or disable cache, compare build hashes, and reproduce in a development build with high-quality source maps.

    The console may be showing the right event, but the wrong address.

    ##

    Most of the time, live inspection is useful. But sometimes you need one hard guarantee: "show me exactly what this value looked like at this moment." That is when you take a snapshot explicitly.

    ###

    const snap = structuredClone(obj)
    console.log(snap)

    Pros:

  • Deep clone
  • Preserves more built-in types than JSON methods
  • Great default in modern runtimes
  • Caveats:

  • Cannot clone functions
  • Cannot clone DOM nodes
  • Can throw on unsupported values
  • ###

    const snap = JSON.parse(JSON.stringify(obj))
    console.log(snap)

    Pros:

  • Works almost everywhere
  • Easy to remember
  • Caveats:

  • Drops `undefined`, functions, symbols
  • Converts `Date` to string
  • Breaks on circular references
  • Loses prototype/class information
  • ###

    import cloneDeep from 'lodash/cloneDeep'

    const snap = cloneDeep(obj) console.log(snap)

    Pros:

  • Mature, predictable deep clone behavior
  • Useful in older environments without `structuredClone`
  • Caveats:

  • Extra dependency
  • Heavier than native options
  • Still not a perfect clone of every possible runtime value
  • ###

    Use snapshots surgically, not everywhere:

    1. Snapshot only at boundaries you care about (before mutation, before retry, before enqueue). 2. Prefer `structuredClone` first. 3. Fall back to JSON method only when you know the data shape is JSON-safe. 4. If cloning cost is too high, log narrow primitives (`id`, `status`, `version`) instead of whole objects.

    ##

    `console.log` is still useful, but it should be your first pass, not your final instrument. Use the tool that matches the question you are actually asking.

    ###

    Use a snapshot.

    console.log(structuredClone(state))

    This avoids live-reference surprises and gives you evidence you can trust later. Cloning can be expensive for large objects, so reserve it for the values you actually need to freeze.

    ###

    Snapshot the relevant DOM facts, not the element object itself.

    console.log({
      path: location.pathname,
      html: element.outerHTML,
      classes: [...element.classList],
      text: element.textContent,
    })

    DOM nodes and collections are often the wrong thing to clone or inspect later. For UI bugs, the useful evidence is usually the route, the element's markup or selected attributes, and the interaction that led there.

    ###

    Use breakpoints and step-through debugging.

    debugger

    Then inspect call stack, scope, and update order directly instead of inferring from scattered logs. The art of debugging is a vast topic itself, but the key is to observe the program's execution in a controlled way.

    ###

    Use framework DevTools (React/Vue/Svelte), not raw logs after update calls. They show lifecycle timing, render phases, and batched updates, which is exactly the scheduling that ad-hoc logging gets wrong.

    ###

    Check your source maps and build artifacts.

    Make sure the JavaScript bundle and source map came from the same build, disable cache while debugging, and reproduce in a development build when possible. In production, treat source-mapped line numbers as clues, not absolute proof.

    ###

    Use structured logging with stable fields.

    logger.info('request_finished', {
      requestId,
      userId,
      status,
      durationMs,
    })

    Production debugging needs queryable logs, correlation IDs, and timestamped events, not console snapshots. Tracing requests across services and time belongs in observability tooling, not the console.

    ##

    Remember, `console.log()` is not broken. It is optimized for speed, interactivity, and convenience, not for preserving exact truth at every point in time. That tradeoff is why you may see confusing behavior around live references, async timing, scheduler boundaries, and framework updates. The logs are often technically correct, but easy to misinterpret. Use the console as a fast exploration tool, then upgrade your approach when the question gets precise:

  • Need exact point-in-time evidence? Snapshot.
  • Need ordering and causality? Debugger and timeline tools.
  • Need production truth? Structured logs with stable fields and correlation IDs.

Do not stop using the console, stop trusting it blindly.

This is part of the same hidden-assumption pattern as Your HTTP Client Is Lying to You, Your Recursion Is Lying to You, and Your Package Manager Is Lying to You.

blog.gaborkoos.com · 14 links

Comments (0)

No comments yet