JS Printing: Console Output vs. Printing a Page

Aug 6, 2026·21 min read

JavaScript has two unrelated operations that both get called printing. Output printing (category 1) sends text somewhere a human reads it: console.log into DevTools or a terminal, or DOM text into the page itself. Page printing (category 2) hands the whole document to the browser’s print pipeline with window.print(), which opens the print dialog and produces paper or a PDF. The trap is that the function actually named print belongs to the second group, so typing print("hello") in a browser console opens a print dialog instead of showing a message, and Node.js has no print global at all.

Two different things called “printing”

Search for how to print in JavaScript and you get two answers that have nothing to do with each other, presented as though they were variations on one idea. They are not. Keeping them apart is most of the work.

Category 1output printingconsole.log(v)el.textContenta screenDevTools, terminal,or the pageCategory 2page printingwindow.print()print(“hi”)print dialogpaper or PDFargument ignoredThe global named print is category 2.Node has no print and no page.
Two categories, two destinations — and the name print sits on the right-hand side.

Output printing means sending a value somewhere a person can read it. That covers console.log writing to the browser’s DevTools console, console.log writing to a terminal in Node.js, and DOM writes that put text into the page. The destination is a screen, and the value is whatever you handed it.

Page printing means asking the browser to lay the current document out for paged media and hand it to a printer or a PDF writer. The destination is paper. Nothing you pass gets displayed, because nothing gets passed.

The ECMAScript language itself defines neither one. console comes from the browser and runtime platforms, and so does print. In a browser, the global print is Window.print(), a category 2 function whose name reads exactly like a category 1 function. In Node.js, there is no print global at all; neither window nor document exists there either.

// Node
console.log(typeof print);
console.log(typeof console.log);
undefined
function

Node’s documented globals include console, process, structuredClone, fetch, queueMicrotask, performance, URL and the timer functions. print is not among them, so the identifier is simply undeclared. That is why a tutorial that says “use print() to output text” is wrong in both environments, for two different reasons.

Every section below sits in exactly one of the two categories and says which, except the last: some of the libraries there build a file rather than print a document.

console.log and the rest of the console API

Category 1. This is the one you want almost every time.

console is not part of the JavaScript language. It is defined by the WHATWG Console Standard, a Living Standard maintained separately from ECMAScript, which is why MDN warns that “Implementations of the console API may differ between runtimes. In particular, some console methods may work differently or not work at all in some online editors and IDEs.” Browsers, Node.js and browser-based sandboxes are all free to render the same call differently.

The Console Standard’s signature for log is undefined log(any... data). Two facts fall straight out of that: it takes any number of arguments, and it always returns undefined.

// Node or browser
const returned = console.log("cart", 3, true);
console.log(returned);
cart 3 true
undefined

Arguments are separated by a space. That is the reason to pass several arguments rather than building one string with +: the console gets the values themselves and can format each one, instead of a string someone already flattened.

The rest of the family covers the common cases. console.error and console.warn mark a message as a problem, and in a browser they usually carry an icon and a stack trace. console.info is a milder log. console.dir prints an object as an inspectable property tree instead of trying to render it as a DOM node. console.table lays an array of objects out in rows and columns.

Format specifiers

The first argument can be a format string. MDN documents %s for strings, %d or %i for integers, %f for floats, %o for optimally useful object formatting, %O for generic object formatting, and %c for applying CSS to the text that follows.

// Node
console.log("%s ordered %d coffees", "Raj", 2);
console.log("%d items", 2.5);
console.log("%i items", 2.5);
console.log("%cheads up", "color: red");
Raj ordered 2 coffees
2.5 items
2 items
heads up

Look at lines two and three. Node’s %d runs the value through Number, so a float stays a float, while %i runs parseInt. MDN describes %d and %i for browsers as integer specifiers. Same source, different result: the Console Standard defines both %d and %i as parseInt(value, 10), so Node’s %d is a deviation from the spec rather than a variation it permits. Line four diverges with the spec’s blessing, because that step still reads “TODO: process %c”: in a browser %c paints the following text red, while Node’s documentation says the specifier is ignored and the CSS argument skipped.

Printing into the page, not the console

Still category 1, with the DOM as the destination. The console is for you; the page is for the user.

The safe default is textContent, which sets the element’s text and nothing else. Combine it with a template literal to interpolate values.

// Browser
const out = document.querySelector("#output");
const name = "Maya";
const guests = 3;

const line = document.createElement("p");
line.textContent = `${name} booked ${guests} seats`;
out.append(line);

innerHTML does something different: it parses the string you assign as HTML. That is what you want when the string is markup you wrote, and a hole when any part of it came from a user or an API response, because a value containing <img src=x onerror=...> becomes an element rather than text.

Two older approaches keep showing up in tutorials. Both put text in front of a user, and neither belongs in code that logs values.

document.write() is marked deprecated on MDN, which says its use “is strongly discouraged. Avoid using it, and where possible replace it in existing code.” It still works; it is discouraged, not removed. The HTML specification lists why: it can affect parser state, it can clear the current page as though document.open() had been called, it may be ignored or throw, and user agents are explicitly allowed not to execute script elements inserted this way. MDN also classes it as an injection sink and an XSS vector.

alert() is a modal dialog. MDN describes it as preventing “the user from accessing the rest of the program’s interface until the dialog box is closed,” cautions against overusing it, and notes that the browser may not display it or wait for dismissal under some conditions, such as when the user switches tabs. For anything you want visible on the page, MDN points at the <dialog> element instead.

Why your object prints as [object Object], and other traps

Category 1 again, and this is where most of the confusion actually lives.

String concatenation flattens objects

The + operator converts its operands to primitives. A plain object’s toString returns the literal string "[object Object]", so the concatenation is doing precisely what it was asked to.

// Node
const booking = { name: "Maya", guests: 3 };
console.log("booking: " + booking);
console.log("booking:", booking);
console.log(JSON.stringify(booking, null, 2));
booking: [object Object]
booking: { name: 'Maya', guests: 3 }
{
  "name": "Maya",
  "guests": 3
}

Passing the object as its own argument lets the console format it. In a browser you get an expandable entry instead of the one-line form Node prints, which brings up the next trap.

The browser console reads objects lazily

MDN states it flatly: “Information about an object is lazily retrieved. This means that the log message shows the content of an object at the time when it’s first viewed, not when it was logged.”

// Browser
const cart = { items: [] };
console.log(cart);
cart.items.push("mug");

Log an empty object, mutate it afterwards, then expand the console entry, and you can see the array element that was added after the log ran. Nothing is broken and nothing is out of order; the console kept a reference and read it when you clicked. It is a reliable way to lose an afternoon to a bug that is not there.

1. log it2. mutate it3. expand itcart.items[]cart.items[“mug”]cart.items[“mug”]keeps a referencenot a copyreads it now,not at step 1the console entryone reference, read when you open itYou see the value at open time, not log time.
The console entry holds a reference; the value you see is read when you open it.

MDN’s fix is to log a deep copy, either JSON.parse(JSON.stringify(obj)) or structuredClone() for broader type support.

// Node or browser
const cart = { items: [] };
const snapshot = structuredClone(cart);
cart.items.push("mug");

console.log(snapshot);
console.log(cart);
{ items: [] }
{ items: [ 'mug' ] }

Node stops at depth 2

Node formats console arguments through util.inspect, whose depth option “specifies the number of times to recurse while formatting object” and defaults to 2. Anything below that becomes [Object]. Pass Infinity or null to recurse fully.

// Node
const config = { server: { tls: { cert: { path: "/etc/ssl/site.pem" } } } };
console.log(config);
console.dir(config, { depth: null });
{ server: { tls: { cert: [Object] } } }
{
  server: { tls: { cert: { path: '/etc/ssl/site.pem' } } }
}

console.table for arrays of objects

When the data is a list of records, console.table beats reading nested braces.

// Node
console.table([
  { name: "Maya", guests: 3 },
  { name: "Raj", guests: 1 },
]);
┌─────────┬────────┬────────┐
│ (index) │ name   │ guests │
├─────────┼────────┼────────┤
│ 0       │ 'Maya' │ 3      │
│ 1       │ 'Raj'  │ 1      │
└─────────┴────────┴────────┘

A second array argument restricts which columns appear, and non-tabular data falls back to plain logging.

Printing in Node.js: stdout, stderr and streams

Category 1 on the server. Node.js has no print, no document and no page, so every option here is output printing.

Node’s documentation is precise about destinations. console.log() “prints to stdout with newline” and console.error() “prints to stderr with newline”. console.info() is an alias for console.log(), and console.warn() is an alias for console.error(). Four names, two streams.

That split is what makes shell redirection useful. Say import.js reports each imported row with console.log and each skipped row with console.error:

$ node import.js > report.txt
row 2 skipped: missing email
$ cat report.txt
row 1 imported
row 3 imported

The report file holds only the successes; the skip notice stayed on the terminal because it went to a different stream. Send diagnostics to console.error and machine-readable output to console.log, and a caller can separate them without parsing anything.

node import.js > report.txtconsole.logconsole.infostdoutreport.txtcapturedconsole.errorconsole.warnstderrterminalstill shownThe redirect captures stdout only.
Four console names, two streams: > captures stdout and leaves stderr on the terminal.

process.stdout.write is the lower-level door. It takes a string and writes exactly that, with no newline appended and no formatting applied.

// Node
process.stdout.write("uploading");
process.stdout.write(".");
process.stdout.write(".");
process.stdout.write(".");
console.log(" done");
uploading... done

One caution that almost never makes it into tutorials. Node’s own documentation warns: “The global console object’s methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams.” It goes on to say that programs depending on that behaviour should first work out the nature of the console’s backing stream, because the stream depends on the underlying platform and the standard stream configuration of the process. So a log line is not automatically a barrier you can order other work around. That kind of question, what the runtime is really doing beneath the JavaScript you wrote, is the subject of Part 9: Under the Hood.

window.print(): sending the page to paper or PDF

Category 2, and here is the payoff of the whole split: the global function named print is the page printer, not the output printer. print("hello") in a browser console opens the print dialog and discards the string, because MDN documents Window.print() as taking no parameters at all. It “opens the print dialog to print the current document”, finishes loading the document first if it is still loading, blocks while the dialog is open, and returns undefined.

It acts on the whole current document. There is no argument for “print this element”.

// Browser
document.querySelector("#print-invoice").addEventListener("click", () => {
  window.print();
});

Because it returns undefined and blocks, there is nothing to await and nothing to chain. Follow-up work belongs in an event handler.

The beforeprint event fires on window when the document is about to be printed or previewed for printing, and afterprint fires after the document has started printing or the print preview has been closed. MDN reports both as Baseline widely available, across browsers since September 2019, and advises preferring @media print CSS wherever CSS will do the job. Treat afterprint as cleanup and test the exact timing per browser rather than assuming it signals a completed or cancelled job.

// Browser
window.addEventListener("beforeprint", () => {
  document.querySelector("#chart-caption").textContent = "Revenue, Q1 to Q4";
});

window.addEventListener("afterprint", () => {
  document.querySelector("#chart-caption").textContent = "";
});

Use those for the things CSS cannot express: expanding a collapsed section, swapping a canvas for a static image, filling in a caption that only makes sense on paper.

Then there is the ceiling, and it is a hard one. You cannot choose the printer, set a copy count, print silently, or read back the status of a job. The WICG Web Printing API explainer, which proposes a global printing object, says exactly why it exists: today’s stack limits developers to selecting a file, limited CSS customisation and triggering the dialog, with “no way to preconfigure a simple task like ‘print 10 double-sided copies of a document on letter paper’”, no access to printer capabilities, no visibility into print job status, and no mechanism for silent printing. It is an incubation proposal, not a shipped standard. Chrome 147 shipped it in stable in April 2026, but for Isolated Web Apps only: a packaged app context, not a page anyone can navigate to.

Category 2, and the half that does the real work. window.print() is one line; the stylesheet is where the output is decided.

Print rules live in @media print blocks, or in a stylesheet loaded with media="print". MDN’s printing guide shows the standard first move, stripping page chrome:

@media print {
  #header, #footer, #nav {
    display: none !important;
  }
}

@page handles the sheet rather than the content. MDN lists it as Baseline newly available since December 2024, with margin and its longhands, size and page-orientation as the descriptors implemented by at least one browser, and the pseudo-classes :first, :left, :right and :blank. Many properties the specification allows inside @page are not yet supported by any user agent, so keep to those.

break-inside stops an element being split across a page boundary. It takes auto, avoid, avoid-page, avoid-column and avoid-region, and MDN lists it as Baseline widely available, across browsers since January 2019. The older page-break-inside is now treated as an alias for it, mapping auto to auto and avoid to avoid, so new code should use break-inside.

print-color-adjust is the one that explains disappearing backgrounds. It controls whether the browser may optimise an element’s appearance for the output device. The default, economy, lets the browser drop background images and adjust colours to save ink; exact preserves them. It was previously named color-adjust, user-agent and user settings can still override it, and MDN lists it as Baseline newly available since May 2025.

A complete stylesheet you can paste and adjust:

/* Browser: print stylesheet */
@media print {
  #site-nav,
  #site-footer,
  .share-buttons {
    display: none !important;
  }

  body {
    font-size: 11pt;
  }

  figure,
  table,
  pre {
    break-inside: avoid;
  }

  .status-badge,
  .code-block {
    print-color-adjust: exact;
  }

  a[href]::after {
    content: " (" attr(href) ")";
  }
}

@page {
  size: A4;
  margin: 18mm;
}

@page :first {
  margin-top: 30mm;
}

@page only affects paged output, so it does not need wrapping in @media print. The a[href]::after rule spells URLs out in the printed text, because a link on paper is a dead end otherwise.

When a library is worth it

The default answer stays window.print() plus @media print. It ships in every browser, costs nothing, and handles the common case, which is “this page, minus the navigation”.

Reach for something else when one of four things is true: the thing you want on paper is not the current DOM at all, you need paged-media features browsers do not implement, you want a PDF file rather than a dialog, or the print target lives inside a component tree that makes a global stylesheet awkward.

The named options, with npm versions as of August 2026:

  • print-js (1.6.0, MIT), “A tiny javascript library to help printing from the web”. It prints a PDF served from your own domain, an HTML element selected by id, images by URL, or JSON data as a table, via printJS(printable, type) or a configuration object. Useful when the target is not simply “the page minus chrome”.
  • react-to-print (3.3.0, MIT), “Print React components in the browser”. Version 3’s API is the useReactToPrint hook taking a contentRef, with options including onBeforePrint, onAfterPrint, documentTitle, pageStyle and a custom print function.
  • Paged.js (pagedjs 0.4.3, MIT), which “chunks up a document into paged media flows and applies print styles”. This is the one for real paged-media work, the parts of the specification browsers do not implement.
  • jsPDF (jspdf 4.2.1, MIT), “PDF Document creation from JavaScript”. A different job entirely: you build the PDF rather than print the DOM.
  • Puppeteer’s page.pdf(), for generating a PDF on the server. It uses the print CSS media type by default, so your @media print rules apply without extra work.
// Browser, React (react-to-print v3)
const contentRef = useRef(null);
const reactToPrintFn = useReactToPrint({ contentRef });
// Node, Puppeteer
await page.pdf({ path: "invoice.pdf" }); // your @media print rules already apply

// opt out of print styles
await page.emulateMediaType("screen");
await page.pdf({ path: "preview.pdf" });

Two notes on the Puppeteer path. Calling emulateMediaType('screen') is how you get screen styles instead of print styles, and its documentation warns that PDFs are generated with adjusted colours unless you set -webkit-print-color-adjust on your elements, which is the server-side echo of the print-color-adjust rule above.

None of these lifts the ceiling from the previous section. A library can choose what goes on the page; the printer, the copy count and the dialog still belong to the user.

Sources:

Frequently asked questions

Why does print('hello') open a print dialog instead of showing a message?
In a browser, the global print is Window.print(), which MDN describes as opening the print dialog to print the current document. It takes no parameters and returns undefined, so the string you passed is discarded. To display a value, call console.log('hello') instead.
How do I print only one part of the page?
Use a print stylesheet rather than building a new document string. Hide the rest behind @media print { #site-nav, #site-footer { display: none !important; } } and then call window.print(). If the thing you want on paper is not simply the page minus its chrome, a library such as print-js takes an element id directly.
Why does my object print as [object Object] in the console?
String concatenation converts the object with toString, and a plain object's toString returns "[object Object]". Pass the object as a separate argument, console.log("cart:", cart), so the console formats it, or use JSON.stringify(cart, null, 2) when you want readable text.
Why does Node print [Object] instead of my nested data?
Node formats console output with util.inspect, whose depth option defaults to 2, so anything nested deeper is replaced with [Object]. Call console.dir(obj, { depth: null }) to recurse all the way down, or use JSON.stringify(obj, null, 2) for a flat text dump.
Can JavaScript print silently, pick a printer, or set the number of copies?
No. window.print() opens the browser's print dialog and the user makes those choices. The WICG Web Printing API explainer proposes a global printing object precisely because there is no way today to preconfigure a job, read printer capabilities, check job status, or print silently, but it is an incubation proposal rather than a shipped standard.