UrsaKit cover for a guide on JavaScript console methods

Most debugging starts with console.log and ends there, which is a shame because the console has a dozen methods and the difference between them is the difference between reading a wall of text and reading an answer. This guide covers the methods worth knowing, the reference-versus-copy behaviour that makes logs look wrong, and how to read a stack trace when an async call fails silently.

Everything here runs in the UrsaKit JavaScript runner, which prints console output with levels, grouping and execution time.

01console.log is only the first tool

console.log writes whatever you give it, in order, with no structure. That is fine for a number and useless for a list of forty records. The rest of the API exists to add structure:

MethodUse it for
console.logAnything; arbitrary values, formatted with %s / %d / %o
console.tableArrays of objects, rendered as a table
console.dirInspecting one object's properties instead of its toString
console.group / console.groupEndNesting related output
console.time / console.timeEndMeasuring elapsed time between two points
console.countCounting how often a branch runs
console.assertLogging only when an assumption fails
console.tracePrinting a stack trace at the current point

02Objects are logged by reference

This is the behaviour that wastes the most time, because the console does not lie — it shows the live object.

const user = { name: 'Ada', plan: 'free' }
console.log(user) // expand later and it may read { name: 'Grace', plan: 'pro' }
console.log(user.name) // 'Ada' — a primitive is captured by value
user.name = 'Grace'
user.plan = 'pro'

In an interactive console the object view is built when you expand it, so it reflects the final state of the object, not the moment you called the method. Three ways to capture the value instead:

console.log(user.name) // log the primitive you care about
console.log(JSON.stringify(user)) // a string snapshot, readable in any console
console.log(structuredClone(user)) // a real deep copy, keeps types

Log the state you are comparing, not the whole object.

console.log('plan=' + user.plan + ' items=' + items.length) survives serialisation, reads the same in a server log and a browser, and cannot be mutated out from under you. Reserve full-object logging for exploratory work.

03Grouping and timing

Related output becomes readable when it is nested, and durations become comparable when they are measured instead of guessed.

console.group('checkout')
console.time('validate')

console.count('line') // line: 1
console.count('line') // line: 2

for (let i = 0; i < 1e6; i++) {
  /* work */
}
console.timeEnd('validate') // validate: 12.4ms

console.groupEnd()

A few details that matter in practice:

  • console.time and console.timeEnd must use the same label, and each label must be ended before it can be started again.
  • console.count starts each label at zero and prints the running total, which is the quickest way to prove a render function runs twice.
  • console.groupCollapsed is the same as console.group but starts closed, which keeps a long run readable.

Timing measured this way includes everything between the two calls, so use it for relative comparisons — before and after a change — rather than as a benchmark. The runner reports the total execution time of the snippet as well.

04Assertions and counting

console.assert is the inverse of console.log: it stays quiet while your assumption holds.

function applyDiscount(price, percent) {
  console.assert(
    percent >= 0 && percent <= 100,
    'discount out of range',
    percent
  )
  return price * (1 - percent / 100)
}

applyDiscount(100, 20) // no output
applyDiscount(100, 150) // Assertion failed: discount out of range 150

Inside a loop, an assertion is a filter: you get output only for the records that break the rule, instead of scrolling past the ones that do not.

05Reading a stack trace

When you need to know which caller reached a function, print a trace instead of adding a log to every candidate:

function parse(input) {
  console.trace('parse called')
  return JSON.parse(input)
}
parse('{"a":1}')
// parse called
//   at parse (<anonymous>:2:11)
//   at <anonymous>:4:1

For thrown errors, the stack is the map from the failure back to the call site. Read it top-down: the first line is where the error was thrown, the frames below are its callers. The frame that belongs to your code — not to a library or the runtime — is usually the one to fix.

try {
  JSON.parse('{ "a": 1, }')
} catch (error) {
  console.error(error.name) // SyntaxError
  console.error(error.message) // Expected double-quoted property name in JSON at position 9
  console.error(error.stack) // where it happened, frame by frame
}

06Async errors that never reach a log

An error thrown inside an async function becomes a rejected promise. If nothing handles it, the failure can happen without ever hitting the line you were watching.

async function load() {
  throw new Error('network down')
}

load() // unhandled rejection: nothing catches this

Two habits make these visible:

  • Await inside try/catch, so the failure lands on a line you control.
  • Handle rejections at the boundary with .catch() on fire-and-forget calls, or listen for the runtime's rejection event in a page you own.
async function load() {
  throw new Error('network down')
}

try {
  await load()
} catch (error) {
  console.error('load failed:', error.message) // load failed: network down
}

Do not ship verbose console output.

Debug logs in production leak internal state to anyone who opens the console, and logging large objects on a hot path costs real time. Gate them behind a flag or remove them before the build.

07Practising in a sandbox

  1. Paste a snippet and run it — The JavaScript runner parses the code first and reports syntax errors with a line number, so you find typos before running anything.
  2. Read the levelled output — Log, warn and error lines are distinguished, and the console groups output the same way the browser does.
  3. Watch the execution timer — The reported duration is the honest cost of the snippet, which is enough to compare two approaches.
  4. Try the failing branch — Re-run with input that breaks the assumption, because the assertion you never triggered is the one that would have caught the bug.

The console is a measurement tool, not a notepad. Using the right method is what turns a page of output into a single line that answers the question.

Frequently asked questions

Why does console.log show the wrong value for my object?

The console stores a live reference to the object, not a copy. If the object is mutated after the log call, the expanded view shows the current state. Log a primitive, a JSON string or a structuredClone copy to capture the value at that moment.

What does console.table actually do?

It renders an array of objects as a table, using the object keys as columns. It is the fastest way to read a list of records, and it accepts a second argument to limit the columns.

When should I use console.assert?

When you want output only if an assumption is violated. console.assert(condition, message) writes nothing while the condition is truthy, which keeps the console clean in normal runs.

Is console the same in Node.js and the browser?

Mostly. table, group, time, count and assert exist in both, but the browser renders interactive objects while Node prints inspect output, and Node adds process-level events such as unhandledRejection.

Do console calls slow down production?

Logging large objects does have a cost, and keeping verbose logs in production leaks data into a place users can read. Remove or gate debug logging before shipping.

How do I see a stack trace without throwing an error?

Call console.trace(). It prints a stack trace at the point of the call, which is how you find which of five callers is responsible.

Back to articles

Related articles

Related tools