Beyond console.log: the console methods worth knowing
console.log answers what is this value. Seven other methods answer how many times, how long, who called it, and which of these rows is wrong.

console.log answers one question: what is this value right now. Most debugging asks a different question, and the console already has a method for it.
| Question | Method |
|---|---|
| Which of these rows is wrong? | console.table |
| How many times did this run? | console.count |
| How long did this take? | console.time / timeEnd |
| Who called this function? | console.trace |
| Is my assumption still true? | console.assert |
| Why is this output such a mess? | console.group |
| What is this DOM element, as an object? | console.dir |
None of them need a library or a build step. Every output below was captured from a real browser console.
console.table
The highest return of any of these. Given an array of objects, it prints a real table with sortable columns instead of a stack of collapsed blobs:
console.table([
{ name: "Ada", age: 36 },
{ name: "Grace", age: 45 },
]);
Ten API results as console.log is ten expandable arrows you have to open one at a time. As console.table it is ten rows, and a wrong value is visible immediately because it sits in a column with its neighbours.
You can limit the columns, which is what makes it usable on real data:
console.table(users, ["id", "email"]);
Objects of objects work too, using the keys as row labels. It is the right first move any time you are looking at more than about three records.
console.count
Answers "did this run, and how many times", without you maintaining a counter variable:
console.count("hit");
console.count("hit");
console.count("hit");
hit: 1
hit: 2
hit: 3
The label is optional and defaults to default. console.countReset("hit") sets it back to zero.
This is the fastest way to check whether a handler is bound twice, whether an effect runs on every render, or whether a code path executes at all. A console.log("here") tells you it ran; console.count("here") tells you it ran eleven times, which is usually the more interesting fact.
console.time and console.timeEnd
Measures elapsed time between two points, with no arithmetic on your part:
console.time("t");
for (let i = 0; i < 1e5; i++);
console.timeEnd("t");
t: 3.710205078125 ms
The labels must match. console.timeLog("t") prints the running total without stopping the timer, which is useful for finding the slow step inside a longer operation.
One caveat worth stating plainly: this measures wall-clock time on one run, on your machine, with your extensions loaded and DevTools open. It tells you that one thing is much slower than another. It is not a benchmark, and small differences between two runs mean nothing.
console.trace
Prints the call stack at the point it runs, without throwing anything:
function save() {
console.trace("save called");
}
The output is the chain of calls that led here. This is the tool for "why is this running at all", and it beats reading the code when a function is called from several places or from inside a library.
It is also the answer to "which of my components triggered this fetch", which is a question console.log genuinely cannot answer.
console.assert
Logs only when the condition is false:
console.assert(1 === 1, "never shown");
console.assert(1 === 2, "assertion failed: 1 is not 2");
Only the second line appears. That inversion is the point. A console.log of a value you expect to be fine adds noise on every run and you stop reading it. An assertion is silent while the assumption holds and speaks up exactly when it breaks.
It is well suited to invariants inside loops:
console.assert(row.id != null, "row without an id", row);
Note that it does not stop execution, unlike a test assertion. It only reports.
console.group
Nests related output so a busy console stays readable:
console.group("group A");
console.log("inside");
console.groupEnd();
The lines between them are indented and collapsible. console.groupCollapsed starts the group closed, which is what you want when logging inside a loop: one line per iteration that you can open only if it looks interesting.
This is the difference between a console you scroll past and one you can actually read.
console.dir
console.log(element) on a DOM node prints the rendered HTML. console.dir(element) prints it as a JavaScript object, with every property expandable.
Use dir when you want to know what the node is, such as its event listeners, its dataset, or its current value, rather than what it looks like.
The trap in logging objects
This is the one that costs people real time, and it comes from console.log being lazy.
When you log an object, the browser stores a reference, not a snapshot. Expanding it later in DevTools shows the object's state at the moment you expanded it, which may be long after the log ran. So an object that was correct when logged can appear wrong, and an object that was wrong can appear to have fixed itself.
Take an explicit snapshot when the value will change:
const o = { n: 1 };
console.log("snapshot", JSON.stringify(o)); // {"n":1}
o.n = 2;
console.log("live ref", JSON.stringify(o)); // {"n":2}
Both lines were captured, and each shows the value at the moment it ran. structuredClone(o) does the same while keeping it an object you can expand.
If you have ever logged a value, seen it look correct, and been unable to explain the bug, this is a strong candidate for why.
Two more habits
Log objects, not values. console.log({ user, cart }) prints the names alongside the values, so a screen of output stays identifiable. console.log(user) followed by console.log(cart) gives you two anonymous blobs.
Use the styled form for anchors. A console.log("%c START", "color: red; font-size: 16px") line is findable when you are scanning hundreds of entries.
Node and the browser are not the same console
Most of these methods exist in both, and three behave differently in ways that matter.
The reference trap is a browser problem. Node serializes the object when the log runs, so the same test that misleads you in DevTools prints honestly:
logged before mutation: { n: 1 }
after mutation: { n: 2 }
Both lines show the value at the time they ran. So if you have been burned by this in the browser, you do not need the JSON.stringify habit in server code.
Node truncates deep objects. Anything past two levels collapses:
console.log({ a: { b: { c: { d: 1 } } } });
// { a: { b: { c: [Object] } } }
[Object] is not an error and not an empty object, it is Node declining to go deeper. This one wastes real time, because it looks like your data is missing. The fix:
console.dir(obj, { depth: null });
%c styling does nothing in Node. The CSS argument is consumed and no colour appears, so styled log helpers written for the browser silently degrade on the server.
console.table does work in Node, printing a proper bordered table, and so does console.count. Those two are worth using in server code as much as in the browser.
Log levels, and why they matter more than they look
console.log, info, warn and error are not four names for the same thing. The browser treats them differently, and using them properly makes a busy console filterable.
warn and error capture a stack trace and appear under the Warnings and Errors filters. debug is hidden unless you enable Verbose. Since DevTools lets you filter by level, a codebase that uses all four gives you a console where you can hide the routine chatter and see only what went wrong.
Using console.log for everything throws that away, and it is the reason so many consoles are unreadable in the first place.
The ones to skip
Not everything on the console object earns its place:
console.clearin committed code is hostile. It erases output somebody else was reading, including logs from before your code ran.console.errorfor expected conditions. A handled 404 is not an error, and marking it as one trains people to ignore red text.console.logleft in production. It leaks internal shape to anyone with DevTools open, and logging a large object keeps it alive in memory because the console holds a reference to it. That is a real, if small, leak, and it is the same reference behaviour described above.
Strip logs at build time rather than by hand. Most bundlers can drop specific console methods, which lets you keep warn and error while removing the rest.
When to stop logging and use the debugger
All of the above share a limitation: you have to know what to log before you run the code. A breakpoint does not.
Set one in the Sources panel, or write debugger; in the code, and execution pauses with every variable in scope available to inspect. You can then step through line by line and watch values change, which answers "where does this go wrong" rather than "what is this value here".
Two features make it worth the switch:
- Conditional breakpoints. Right-click a breakpoint and give it a condition such as
id === 42, and it pauses only on the iteration you care about. This replaces the entire pattern of wrapping a log in anif. - Logpoints. Same menu, but instead of pausing it prints an expression. That is a
console.logyou did not have to add to the source, and did not have to remember to delete.
The rule of thumb: reach for a log when you have a specific question about a specific value, and for the debugger when you do not yet know what to ask. If you are on your third round of adding logs and re-running, you wanted a breakpoint two rounds ago.
For getting around the panels themselves, Chrome DevTools for beginners covers the 20 percent you use daily.
Want somewhere to practise reading output rather than guessing at it? Start with the JavaScript track.
More from the blog

git cherry-pick: move one commit anywhere
Cherry-pick copies a commit onto your current branch. It creates a new commit with a new hash, so the original stays where it was.
Read more
CORS error: what the browser is actually blocking
A CORS error is the browser refusing a response the server already returned with a 200. Here is that proved with two real origins, and the fixes.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.