Beyond console.log(): Exploring the Console Object in JavaScript

In this post, we’ll explore the full console API from basic usage to advanced debugging techniques.


Prerequisites

This is the list of all the prerequisites:

  • Basic understanding of JavaScript syntax
  • Basic knowledge of browser Developer Tools
  • A modern web browser such as Google Chrome or Mozilla Firefox

You can open the browser console using:

  • Chrome / Edge: F12 or Ctrl + Shift + J
  • Firefox: F12 or Ctrl + Shift + K
  • Mac: Cmd + Option + J

Overview

Most JavaScript developers use three console methods: logwarn, and error. The other twenty-plus go mostly untouched — which is a missed opportunity. The console API is a surprisingly capable debugging toolkit, and knowing it well can cut down on the time you spend staring at the DevTools trying to make sense of output.

What the Console Object Actually Is

The console object is a globally available namespace exposed by the browser and the Node.js runtime. It is not defined by the ECMAScript specification. It is a Web API — defined by the WHATWG Console Living Standard. This means behavior can vary between environments, and some methods (especially profiling ones) are non-standard.

Internally, every console method routes through a Logger that processes your arguments (applying format substitutions if the first argument is a string with specifiers), then passes the result to a Printer — an implementation-defined sink responsible for the actual display. The Printer respects the active group stack for indentation and buffers messages if no console is attached.

The spec groups methods into four severity tiers:

This grouping is what browser DevTools use to categorize and filter console output. When you filter by “Errors” in Chrome DevTools, only the error tier shows — everything else is hidden. Understanding this helps you design your debug output so filtering works for you rather than against you.

Log Level Methods

Beyond console.log(), the console exposes five logging methods, each mapping to a severity level.

console.debug("Verbose detail for deep debugging");
console.log("Standard informational output");
console.info("Structured informational message");
console.warn("Non-fatal: something looks off");
console.error("Fatal or unexpected: this should not happen");

console.debug() maps to the Verbose level in Chrome DevTools. It is hidden by default — you have to explicitly enable Verbose in the level filter to see it. This makes it genuinely useful for noisy, low-level tracing that you want available during development without polluting normal output.

console.info() and console.log() behave identically in most environments. In some browsers, info adds a small information icon in the DevTools panel, but the functional behavior is the same. Prefer info for structured messages about system state — startup banners, configuration summaries, lifecycle transitions.

console.warn() outputs to process.stderr in Node.js. In browsers, it renders with a yellow triangle. Reserve it for conditions that are recoverable but worth flagging — deprecated API usage, suspicious input values, configuration fallbacks.

console.error() also routes to stderr in Node.js and renders with a red X in browsers. Use it for unambiguously bad situations: caught exceptions, failed network calls, invariant violations. In Node.js, console.error() is the correct choice for anything that monitoring systems should capture.

Using these methods consistently means your logs become filterable. A developer triaging a production issue can filter to error alone and see only what matters.

Format Specifiers

When the first argument to any logging method is a string, the Logger runs the Formatter, which processes substitution directives left-to-right, consuming additional arguments in order.

const user = { id: 42, name: "Alice" };
const requestCount = 7;

console.log("User %o made %d requests", user, requestCount);

Remaining arguments after all specifiers are consumed are appended to the output with a space separator — the same way concatenating arguments without a format string works.

Styling Console Output with %c

The %c specifier applies a CSS style declaration to the text that follows it. Each %c in the format string consumes one style argument.

console.log(
"%cError%c Something went wrong in %s",
"color: white; background: red; padding: 2px 6px; border-radius: 3px; font-weight: bold;",
"color: inherit;",
"paymentService"
);

This produces a red badge reading “Error” followed by normal-styled text. It is genuinely useful for distinguishing log categories in a busy console without filtering — you can visually group output from different modules at a glance.

The supported CSS subset includes: colorbackgroundfont and its longhand properties, borderborder-radiuspaddingmargintext-decorationline-heightword-spacing, and white-space. Note that console messages are inline elements by default — if you need padding and margin to take effect, add display: inline-block to your style.

For environments that support both light and dark DevTools themes, use light-dark():

console.log(
"%cWARN",
"color: light-dark(#7a4f00, #ffcc00); background: light-dark(#fff3cd, #3a3000); padding: 2px 6px; border-radius: 3px;"
);

Styling is a browser-only feature — it has no effect in Node.js, where %c directives and their arguments are silently ignored.

Visualizing Structured Data

console.table()

console.table() renders arrays of objects or object maps as a formatted ASCII table. The first column is always the index (or key), and subsequent columns are the object properties.

const endpoints = [
{ method: "GET", path: "/users", status: 200, latencyMs: 45 },
{ method: "POST", path: "/users", status: 201, latencyMs: 120 },
{ method: "DELETE",path: "/users/7", status: 403, latencyMs: 18 },
];

console.table(endpoints);

In Chrome DevTools this renders an interactive, sortable table. In Node.js it renders a Unicode box-drawing table:

The optional second argument filters which columns appear:

console.table(endpoints, ["method", "status"]);

This is particularly useful when inspecting large objects where most properties are noise — you surface only the fields you care about.

console.table() works with any iterable of objects, including Maps (though Map entries display differently since they are key-value pairs, not property bags).

console.dir()

console.dir() forces the generic JavaScript object representation on whatever you pass it, regardless of type. In browsers, this is the expanded property tree view. In Node.js, it uses util.inspect() internally.

console.dir(document.body);
// Shows the DOM element as a property tree, not as HTML markup

In Node.js, the second argument passes options directly to util.inspect():

console.dir(complexObject, {
depth: 4, // Recurse 4 levels deep (default is 2)
colors: true, // ANSI color codes
showHidden: true // Include non-enumerable properties
});

The depth option is the most important one in practice. The default depth of 2 means deeply nested objects are shown as [Object] or [Array] — frustrating when you’re trying to inspect a configuration tree or a deeply nested API response. Raising it to null disables the limit entirely, though this can produce enormous output for circular-reference-heavy objects.

console.dirxml() is included in the spec and in browsers renders an element as its XML/HTML markup tree — the same view you get in the Elements panel. In Node.js it simply delegates to console.log(), so it is effectively useless server-side.

Grouping Log Output

Groups collapse related log statements under an expandable label. They solve a specific problem: when a single operation generates many log lines, those lines are hard to associate with each other when interleaved with output from other operations.

const label = 'Teenage Mutant Ninja Turtles';

console.group(label);

console.info('Leo');
console.info('Mike');
console.info('Don');
console.info('Raph');

console.groupEnd(label);

In DevTools, each console.group() creates a collapsible section with a disclosure triangle. console.groupCollapsed() creates the same structure but starts collapsed — useful for verbose output you want available but not immediately visible.

In Node.js, groups increase indentation by two spaces per level (configurable via the Console constructor’s groupIndentation option). There is no collapse behavior since the output is a stream.

One practical pattern: wrap the logging for each request or background job in a group labeled with an ID. When you’re scanning output, you can collapse groups for completed requests and focus on the one that’s behaving unexpectedly.

Timing Code with the Console

The console exposes a built-in timer API that is far more ergonomic than manually capturing Date.now() differences.

console.time("fetchUsers");

const users = await db.query("SELECT * FROM users WHERE active = true");

console.timeLog("fetchUsers", `fetched ${users.length} rows`);

const enriched = await Promise.all(users.map(enrichWithPermissions));

console.timeEnd("fetchUsers");
// fetchUsers: 243.812ms

The three methods:

  • console.time(label) — starts a named timer. If a timer with that label already exists, the spec requires an early return (no restart, no error). The default label is "default".
  • console.timeLog(label, ...data) — logs the elapsed time without stopping the timer. Extra arguments append to the output.
  • console.timeEnd(label) — logs the elapsed time and removes the timer entry. Subsequent calls with the same label start fresh.

Timer labels are scoped per console namespace. Browsers support up to 10,000 simultaneous timers per page.

The output precision is implementation-defined. Modern engines report sub-millisecond precision (e.g., 243.812ms). This is not a substitute for the Performance API (performance.now()PerformanceObserver) in production — but for development-time profiling it requires zero setup and produces readable output inline with your other logs.

Counting Function Calls

console.count() maintains an internal counter per label and logs the current count each time it is called. console.countReset() resets the counter for a label to zero.

function handleRequest(req) {
console.count(`${req.method} ${req.path}`);
// ... handle request
}

handleRequest({ method: "GET", path: "/api/products" });
handleRequest({ method: "GET", path: "/api/products" });
handleRequest({ method: "POST", path: "/api/products" });
handleRequest({ method: "GET", path: "/api/products" });

The default label is "default" when called with no arguments. Calling console.countReset() with a label that does not exist emits a warning rather than silently failing.

console.count() is most useful when you want to know how many times a code path is hit during a test run, without setting breakpoints or adding manual counters. It is not designed for production instrumentation — it has no export mechanism and resets when the page reloads or the process restarts.

Assertions

console.assert() writes an error message only when the first argument is falsy. When the assertion passes, nothing is logged.

function divide(a, b) {
console.assert(b !== 0, "Divide by zero: b must not be 0, got %d", b);
return a / b;
}

divide(10, 2); // — no output
divide(10, 0); // Assertion failed: Divide by zero: b must not be 0, got 0

The format is identical to console.error() — the message supports format specifiers and the output is routed through the error severity channel. In browsers, DevTools shows an assertion failure with a stack trace.

console.assert() does not throw. It logs and continues. If you need execution to stop on failure, you still need to throw explicitly. Think of it as a lightweight runtime sanity check for development and testing environments, not a replacement for proper validation.

Profiling Methods

console.profile() and console.profileEnd() integrate with the browser’s built-in CPU profiler:

console.profile("render");
expensiveRenderOperation();
console.profileEnd("render");

The captured profile appears in the DevTools Performance panel (or Profiler tab in Firefox). These methods are non-standard and may not be available in all environments. In production-grade profiling workflows, the Performance API (performance.mark()performance.measure()) is more reliable and portable.

console.timeStamp() adds a marker to the performance timeline, useful for annotating where specific events occur in a recording.

Stack Traces

console.trace() prints the current call stack to the console. Optional arguments become a label prepended to the trace.

function c() { console.trace("Where am I?"); }
function b() { c(); }
function a() { b(); }
a();

In Node.js, console.trace() outputs to stderr. In browsers, it links each frame to the source file, making navigation to the call site one click away.

console.trace() is invaluable when debugging unexpected call sites — for example, when a callback is triggered more times than expected or from a caller you didn’t anticipate. Rather than manually threading call-site information through arguments, console.trace() captures the full context at the point of interest.

The Node.js Console Class

In Node.js, the global console is an instance of the Console class pre-configured with process.stdout and process.stderr. You can instantiate your own:

const fs = require("node:fs");
const { Console } = require("node:console");

const appLog = new Console({
stdout: fs.createWriteStream("./app.log"),
stderr: fs.createWriteStream("./error.log"),
colorMode: false,
groupIndentation: 4,
});

appLog.log("Application started");
appLog.error("Failed to connect to database");

The constructor accepts an options object with:

Custom Console instances are the correct way to add structured file logging to a Node.js application when you don’t want a third-party logging library. The separation of stdout and stderr streams means you can route them independently — for example, piping stdout to a log aggregator and stderr to an alerting system.

Important: The global console in Node.js is neither consistently synchronous nor asynchronous. In practice, writes to a TTY (interactive terminal) are synchronous, but writes to files or pipes are asynchronous. This matters if you’re logging near a process.exit() call — the write may not flush before the process terminates. To guarantee flushing, listen for the finish event on the underlying stream, or use a library designed for this.

The Lazy Object Reference Problem

This is the most commonly misunderstood behavior of the console, and it causes confusing debug sessions.

const config = { debug: false };
console.log(config); // Logs a reference, not a snapshot
config.debug = true;
// DevTools may show: {debug: true} — the mutated value

When you log an object, browsers store a live reference. The DevTools panel displays the object’s state at the time you expand it, not at the time console.log() was called. If you mutate the object between the log call and expanding it in DevTools, you see the mutated state.

To capture a snapshot at log time:

// Shallow copy
console.log({ ...config });

// Deep copy for nested objects
console.log(structuredClone(config));

// Quick but lossy — drops functions, undefined, and special types
console.log(JSON.parse(JSON.stringify(config)));

structuredClone() is the correct modern choice — it performs a deep clone, handles circular references (by throwing, which surfaces the problem), and preserves most JavaScript types. The JSON round-trip loses undefinedFunctionDate precision, and Map/Set structure, which can make debugging worse by hiding relevant state.

Production Considerations

Strip debug logs in production. console.log() and console.debug() calls have real costs: string formatting, object serialization, and output I/O all take CPU time. In high-throughput Node.js services, leaving verbose console output enabled measurably degrades throughput. Build tools like Vite, webpack, and esbuild support plugins that strip or replace console calls at build time.

Use severity intentionally. If everything is console.log(), log filtering is useless. Adopting even a minimal discipline — debug for tracing, info for lifecycle, warn for degraded behavior, error for failures — makes production debugging dramatically faster.

Don’t expose sensitive data. Logging objects from user requests, authentication flows, or payment handling is a common way to accidentally write sensitive data to log files or aggregators. Be explicit about what you log — prefer structured messages with specific fields over console.log(req).

Consider a proper logger for production Node.js. Libraries like pino and winston emit structured JSON, support log levels with runtime toggling, are designed for async I/O, and integrate with log aggregation platforms. The console API is excellent for development and lightweight scripts — for production services it is usually the wrong tool.

Conclusion

🏁 Well done!! In this article, we explored the JavaScript Console object beyond console.log() and discovered powerful tools for smarter debugging and cleaner development.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉 Link to Medium blog

Related Posts