The day a Node app takes its first paying user, plain console.log lines stop being enough: during an outage nobody can filter them by level or pull out one request. Pino logging fixes both. Pino is a Node.js logger that writes one JSON object per line, with 6 named levels numbered 10 to 60 and a child logger that stamps each request’s id.
What is pino? Pino logging and the setup for a Node app
Pino is an open-source logger for Node.js that writes one JSON object per line and does as little work as it can in the request path. A Node app needs one logger module, a level read from the environment, and a child logger per request that carries the request id.
Each line holds a numeric level, a time, the message under msg and whatever fields you pass, and it goes to stdout unless you name another destination. Settle the field names before the first line ships: a timestamp, a level, an event name, the request id and the user’s id are the core of how to do logging in a web app, and pino only has to produce them.
Pino is on npm as pino (installed with npm install pino), its source sits in the pinojs organization on GitHub, and pino’s API documentation lives at getpino.io. The setup I use is one logger module for the whole app, created once. The level comes from an environment variable, base adds the service and environment to every line (replacing the default process id and hostname), and pino.stdTimeFunctions.isoTime writes the time as an ISO 8601 string in UTC. The docs warn that formatting time in-process will significantly impact logging performance; for a small app I take the readable timestamp anyway.
// npm install pino
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: 'orders-api', env: process.env.NODE_ENV },
timestamp: pino.stdTimeFunctions.isoTime,
});
const log = logger.child({ request_id: 'req-7f3a' });
log.info({ order_id: 42 }, 'order loaded');
// {"level":30,"time":"2026-10-03T09:14:02.511Z","service":"orders-api","env":"production","request_id":"req-7f3a","order_id":42,"msg":"order loaded"}
logger.child({ request_id }) returns a logger whose bindings become top-level fields on every line it writes, so each line from that request carries the id. Create the child once per request in middleware (pino-http, further down, does it for you as req.log) and pass it along. Pino’s docs add a warning worth keeping: never build a child from a user-supplied object, because its keys become top-level fields that can collide with level, time or msg.
If your event shape calls the message message and wants the level as a word, messageKey: 'message' renames the key and a formatters.level function can return the label in place of the number. The same docs carry one catch: with several transport targets the level has to stay numeric, because pino routes each line by that number.
Pino’s README and benchmark page present it as faster than the alternatives in many cases, but the benchmark page carries no date, so I leave the multiple out. For a small app the setup above decides more than the margin does.
This page covers one piece of logging and monitoring for a small SaaS: the library that writes the lines.
Pino log levels: six names, six numbers
Pino log levels are six names with six numbers: trace 10, debug 20, info 30, warn 40, error 50 and fatal 60. The default is info, so lines below it are skipped until the level is lowered through configuration rather than a code change.
| Level | Number | What I log at it in a small SaaS |
|---|---|---|
trace | 10 | Step-by-step detail while chasing one bug on my own machine |
debug | 20 | Values that help reproduce a bug, switched on for a short window |
info | 30 | One line per thing that happened: a signup, a payment, a finished job |
warn | 40 | A retry or a fallback that still gave the user an answer |
error | 50 | A request or job that failed and needs a look |
fatal | 60 | The process is about to exit |
silent | Infinity | Nothing: it turns logging off, which I use only in tests |
A call is written only when its number is at or above the configured level, so at info (30) every trace and debug call does nothing. In the JSON the level is a number by default ("level":30), and the formatters.level function from the setup section can print the label instead. The third column is my own advice, not pino’s. Change the level per environment through LOG_LEVEL in the host’s settings, never by editing code before a deploy. Which levels stay on in production, and why, is a policy question of its own: logs severity levels in production.
pino-pretty in development, plain JSON in production
pino-pretty is a separate package that turns pino’s JSON lines into colored text for a terminal. It belongs in development: an environment check decides whether it loads, and production writes plain JSON so every field stays searchable in the log store.
pino-pretty turns a line like {"level":30,"time":1522431328992,"msg":"hello world","pid":42,"hostname":"foo","v":1} into [17:35:28.992] INFO (42): hello world. Its README gives two ways to load it: pipe the process output into the CLI with node app.js | pino-pretty, the way it recommends, or set the logger’s transport target to 'pino-pretty'. On production it is direct: “We recommend against using pino-pretty in production and highly recommend installing pino-pretty as a development dependency.”
So install it with npm install --save-dev pino-pretty and load it behind a check such as transport: process.env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty' }. The piped form is simpler still, because the production start command never mentions it. In production the JSON goes to stdout untouched and the host captures it.
The mistake to avoid is pretty output reaching the log store. Every line turns into one string of text, and no field can be filtered. A production build that installs only production dependencies but still names pino-pretty as a target fails with the transport error answered in the questions at the end.
Pino transports: where the lines go after stdout
Pino transports are modules that receive log lines and send them to a destination, running in a worker thread so the send does not block a request. On a managed host the simplest transport is none: write JSON to stdout and let the platform collect it.
Since pino v7 a transport can run inside a worker thread: the main thread writes lines to the worker, and the worker writes them to the destination. Pino’s transports documentation covers the built-in pino/file and a long list of known transports, most of them from other authors.
| Destination | Transport | Runs where | The catch |
|---|---|---|---|
| stdout, collected by the host | None (stdout is pino’s default destination) | Main thread | Search reaches back only as far as the host keeps logs |
| A file | pino/file | Worker thread | Throws if the directory is missing, unless mkdir: true; inside a container the file goes when the container does |
| Rotating files | pino-roll | Worker thread | Rolls by size or time; the same container problem as one file |
| Grafana Loki | pino-loki | Worker thread, its README’s recommended way | Needs the Loki URL in host; batches lines, every 5 seconds by default; maintained outside the pinojs organization |
| A hosted log service | The service’s own transport, or the host’s log drain | Worker thread, or outside the app | Set up from that service’s own docs |
| Several of the above | targets, each with its own level | Worker thread | A target with no level gets info; the level field must stay a number |
Stdout is my default on Vercel, Render, Railway and Fly.io style hosts, because the platform already collects it. pino-loki sends lines straight to a Loki instance, which its README offers for setups where running an agent that reads log files is not possible. Rotation, if you write files at all, is pino-roll’s job.
Short-lived serverless functions are where a worker thread gets awkward. Pino’s docs say the new transports boot asynchronously, that calling process.exit() before the transport is ready loses lines, and that pino.transport() listens for beforeExit and exit to flush the worker. Pino’s asynchronous logging docs add that in AWS Lambda, asynchronous logging tends to delay or lose lines because they may not be written before the runtime is frozen, and recommend a destination with sync: true there; a worker-thread transport runs asynchronously unless its sync option is set. My rule there is no transport at all, only JSON to stdout. Bundlers are the other trap: pino’s docs say a bundled app has to ship the transport files separately, and the questions at the end cover the fix.
Searching and graphing those lines, and turning them into numbers, is a separate job: Prometheus metric types and Grafana. So is retention, how long to keep application logs, which sets how far back the first row of that table can reach.
Why it matters: js logging past console.log
Across my audits, 17 of the 21 third-party apps had no error tracking or alerting: when a user hits an error, nothing records it. Those 21 are the 11 public apps and the 10 held-out apps I audited in June and July 2026, chosen by me, so the count is not a rate for Node apps.
Most Node.js logging in a young app starts as console.log. In Node, console.log prints to stdout with a newline and console.error prints to stderr; console.info and console.debug are aliases for console.log, and console.warn is an alias for console.error. Node’s console documentation also warns that these methods are neither consistently synchronous nor consistently asynchronous, and Node’s note on process I/O adds that on POSIX systems, writes to a file or a terminal are synchronous and writes to a pipe are asynchronous.
| What you need during an outage | console.log by default | pino |
|---|---|---|
| A level to filter by | None; console.info and console.debug are the same call | Six levels, a number on every line |
| A timestamp | Only what you put in the message | time on every line, epoch milliseconds by default or ISO with isoTime |
| One shape per line | Whatever the arguments format to, printf-style | One JSON object per line with named fields |
| The request id on every line | Only where each call passes it | A child logger stamps it on every line |
| Secrets removed before the line is written | No; the line holds whatever was passed | redact paths replace values with [Redacted] |
| Detail turned up through configuration | No; it takes a code change | level read from an environment variable |
A hand-built Node.js console logger, a small wrapper that adds a level and a timestamp before calling console.log, is a fair first step. Pino is that idea carried all the way through: levels, JSON lines, child loggers and redaction in one package.
Whether console output alone can carry a production app is answered in error logging best practices for a solo app, along with choosing levels by the response they need, a redaction policy and wiring an error tracker.
A medical-advice app I audited printed patient vitals and its AI provider key straight into the server logs, making a second, less-guarded copy of both, one of the gaps behind insufficient logging and monitoring. That log copy is one more place where personal data lives, so it belongs on a data map GDPR reviewers accept. A log line carries whatever the code handed it, secrets included, so removal has to happen before the line is written, which is the job of pino’s redact paths.
How it works: winston, morgan, the browser side, and which one to pick
Pino is one of three Node loggers that come up together, and the other two do different jobs; the browser is a fourth case with its own limits. What follows about each library comes from its own docs and README, not from a benchmark or a test of mine.
Winston logger: createLogger, levels, formats and transports
Winston is a general-purpose Node.js logger built with createLogger from three parts: a level, a format and a list of transports. Its levels run the opposite way to pino’s, with error at 0 and silly at 6. For production, combine the timestamp, errors and json formats.
Winston on GitHub calls itself “a simple and universal logging library with support for multiple transports”, and winston on npm installs with npm install winston into any JavaScript app running on Node.js. Its README recommends creating your own logger with winston.createLogger, whose defaults are level info, the npm levels, the json format and no transports at all.
const { createLogger, format, transports } = require('winston');
const logger = createLogger({
level: process.env.LOG_LEVEL || 'info',
format: format.combine(
format.timestamp(),
format.errors({ stack: true }),
format.json()
),
transports: [new transports.Console()],
});
logger.child({ request_id: 'req-7f3a' }).error(new Error('order lookup failed'));
| Winston level | Number |
|---|---|
error | 0 |
warn | 1 |
info | 2 |
http | 3 |
verbose | 4 |
debug | 5 |
silly | 6 |
Winston logging levels follow npm’s convention, and winston uses them unless you define your own. In winston a lower number is more severe; in pino a higher number is. That matters the day two libraries feed one log store: pino writes an error as "level":50 and winston writes it as "level":"error", so a filter written for one form misses the other library’s errors until both are mapped to a common field.
Winston formats live in a separate package, logform, and format.combine chains any number of them. For production, timestamp() adds an ISO time, errors({ stack: true }) keeps the stack trace when you log an Error, and json() writes the result as JSON. Winston colorize, format.colorize(), is for a terminal, and the README is specific about its place in the chain: “The colorize formatter must come before any formatters adding text you wish to color.” Keep it out of any format whose output goes to a log store, because the color escape codes end up inside the field values.
Four transports are built in (Console, File, Http and Stream), winston’s contributors maintain more such as DailyRotateFile, MongoDB and Syslog, and each transport can carry its own level. The DailyRotateFile transport, from the winston-daily-rotate-file package, handles rotation. For the request id, logger.child({ request_id }) works as it does in pino, with the README’s caveat that .child “is likely to be bugged” if you also extend the Logger class.
Winston is the right pick when the app already runs it, or when the team knows its format chain and wants several destinations with their own levels. Moving a working winston setup to pino is rarely worth the day it takes.
Morgan: the HTTP request line, and nothing else
Morgan is HTTP request logging middleware for Node.js and Express that writes one line per request: method, URL, status, size and, in most formats, response time. It has no levels and no application events, so it sits next to a logger and never replaces one. It ships five predefined formats, from tiny to combined.
Morgan on GitHub sits in the expressjs organization; on npm (npmjs.com) the package is plain morgan, installed with npm install morgan and mounted with app.use(morgan('combined')), or app.use(morgan('dev')) for output colored by status in a development terminal. Morgan in Node.js is not an application logger: it has no logger.info to call, which is why “morgan or winston” is the wrong question. An app that uses morgan still needs one of the other two for everything that is not a request line.
| Format | What it prints | Use it for |
|---|---|---|
combined | Standard Apache combined output: address, user, date, request line, status, size, referrer, user agent | Access-log records |
common | Standard Apache common output: the same without referrer and user agent | Shorter access-log records |
dev | Method, URL, status colored by response class, response time, size | A terminal in development |
short | Address, user, request line, status, size, response time | A compact record with timing |
tiny | Method, URL, status, size, response time | The minimum |
Morgan logging is text by default. For a JSON store there are two documented routes: a format function that builds the line from the tokens, or a stream in object mode, where a format function that returns an object has that object written to the stream as-is. The second is how morgan feeds a logger that accepts objects; for winston the object needs level and message keys, since every winston entry must have both. Morgan writes to process.stdout unless you give it a stream, and skip: (req, res) => req.url === '/health' keeps health checks out.
On the pino side the equivalent is pino-http: app.use(pinoHttp()) logs each finished request as one JSON line with req, res and responseTime, and attaches a child logger to the request as req.log. Its request id is an integer unless you pass genReqId; the README’s own example reads an incoming x-request-id header, falls back to randomUUID() and sets X-Request-Id on the response. If the host or a proxy in front of the app already writes an access log, the app may not need either library for the request line. The fields that belong on a request line are part of the event shape from the first section.
React logging: what the browser side can and cannot log
React logging means console calls that run in the user’s browser, where the team never sees them. Two things are worth sending back: uncaught errors and failed API calls with the server’s request id. Both go to an error tracker or to one rate-limited server endpoint.
React has no logger of its own. React logs, in the everyday sense, are console calls that run in each visitor’s browser and stay there. A React.js logger that earns its place does two jobs: it catches uncaught errors, through an error boundary for render errors and window.onerror for other uncaught errors, and it records failed API calls together with the request id the server returned. Send both to an error tracker, or to one small server endpoint that writes them with the server logger; rate limit that endpoint and treat everything it receives as untrusted input.
For React.js logging past that, pino ships a browser build. By default it maps each level to the matching console method and sends fatal to console.error, and its transmit option takes a send function that is called after a log message is written, which is where a line can be forwarded to your endpoint. Pino’s browser API lists the parts that work only in Node, pino.transport() among them.
Two rules of mine for client-side logging in React: never log tokens, form contents or personal data there, and strip console.log debugging out of production bundles. Wiring the error tracker itself is in the error logging article from the section above.
Pino, winston or morgan: match the logger to what you are logging
Node logger choice follows what is being logged: application events in a new app go to pino, an app already on winston stays on winston with JSON output, the HTTP request line goes to pino-http or morgan, and browser errors go to an error tracker. Pick one application logger per app.
| What you are logging | Pick | Why | The one setting to change |
|---|---|---|---|
| Application events in a new or small Node app | pino | JSON by default, child loggers, little configuration | level from an environment variable |
| Application events in an app already on winston | winston | Already wired; switching buys little | format.json() in the logger’s format |
| The HTTP request line on Express | pino-http if the app uses pino; morgan if it uses winston or nothing | One line per request in the shape the other logs use | pino-http: genReqId; morgan: a format function that returns JSON |
| Browser errors | An error tracker, not a logger | The browser console lives on the user’s machine | Send the server’s request id with each error |
Here is one GET /api/orders/42 that returns 500, logged three ways (trimmed; values are illustrative):
pino-http {"level":30,"time":1791018842511,"req":{"id":1,"method":"GET","url":"/api/orders/42"},"res":{"statusCode":500},"err":{"type":"Error","message":"failed with status code 500"},"responseTime":12,"msg":"request errored"}
winston {"level":"error","message":"order lookup failed","stack":"Error: order lookup failed\n at ...","timestamp":"2026-10-03T09:14:02.511Z"}
morgan ::1 - - [03/Oct/2026:09:14:02 +0000] "GET /api/orders/42 HTTP/1.1" 500 21 "-" "curl/8.7.1"
Only the pino-http line carries a request id and named fields without extra work, and even its id is the default integer until genReqId replaces it. Its level is 30 because pino-http logs every response at useLevel, info by default; the README’s customLogLevel example returns error for status 500 and above. The winston line comes from an error handler in the app that calls logger.error, and the morgan line stays text until a format function turns it into JSON.
For context, not as a reason to choose: npm’s download counts for the week of September 24 to 30, 2026 were about 61.1 million for pino, 32.6 million for winston and 15.5 million for morgan. Two application loggers in one app is the setup I avoid, for the level mismatch described under winston.
The same decision for Python is logging in a Python web app with the Flask logger, and carrying one id from service to service is what a trace id is.
How to check your own app
Node logging is verified with five checks: find every line of one request by its request id, confirm a test error carries a stack, send fake secrets and find none in the logs, confirm lines survive a restart within the host’s retention, and raise the level to debug and back through configuration.
The checks assume an Express app with pino and pino-http on a long-running host (Render, Railway, Fly.io style) or a staging copy with production settings. On Vercel functions, runtime logs are kept for 1 hour on Hobby, 1 day on Pro and 3 days on Enterprise, or 30 days with Observability Plus, so run each search inside that window.
- 01 Send one request with a known X-Request-Id header and search the logs for that id. Find every line for it: the request line and the application events. Evidence: the query and its result.
- 02 In the staging copy only, call a route that throws. Confirm one line at level 50 (error) with a stack trace and the same request id. Evidence: the line.
- 03 Send a request carrying an obviously fake password field, a fake bearer token and a fake email address. Wait until that request's own line is visible, then search for the three strings and find none. Evidence: the request line found, three empty searches.
- 04 Restart the process or redeploy, then search for a line written before the restart. It should still be there, inside the host's retention window. Evidence: a line with a timestamp before the restart.
- 05 Set LOG_LEVEL to debug in the host's settings, confirm debug lines appear, then set it back. Evidence: the two lines and their times.
Check 1 depends on the id. pino-http numbers requests with an integer unless you pass genReqId, so use the README’s version, which reads x-request-id and, when a request has none, generates an id and sets it in the X-Request-Id response header; if the app accepts no incoming header, read the id from that response header instead. Check 2 needs your error handler to log through the request’s req.log, or a customLogLevel that maps 500 to error, because pino-http otherwise records a failed response at info.
In check 3, pino’s redact option is what removes the fields. pino-http’s default request record includes the request headers, so redact: ['req.headers.authorization', 'req.headers.cookie'] is the minimum; bodies stay out unless you add them, which pino-http leaves off by default. Check 4 fails when the logs live only in a file inside the container. Check 5 takes a redeploy on Vercel, where environment variable changes apply only to new deployments, and on Render a “Save and deploy” or “Save, rebuild, and deploy”, since “Save only” leaves the running service on the old values.
Where the sprint fits
In the Production Hardening Sprint, deliverable 8.1, Structured, sanitized logs, reads in the published scope: “Add structured request logs with correlation IDs and appropriate user references; exclude passwords, tokens, and unnecessary personal data.” We verify it this way: “Trace a test request across services and check log content for sensitive fields.” Hosting, paid tools, and API usage remain in your accounts.
Common questions about Node loggers
What is the best logger library for Express?
No single library wins for every Express app: for a new one I use pino with pino-http, and for an app already on winston, winston with morgan feeding it JSON objects. Either way the rule is one application logger and one line shape.
What does the error “unable to determine transport target for “pino-pretty” #4463” mean and how can I fix it?
It means pino could not load pino-pretty, the module named as a transport target, when the logger started. A target is an installed module name or an absolute path, so the error appears when the module is missing from the running app: in AdonisJS discussion #4463 a production-only install left out pino-pretty, a dev dependency, and moving it to dependencies fixed it.
The cleaner fix is to stop naming pino-pretty in production at all, by loading it behind an environment check or piping to it only in development. A bundled app has a different cause: pino’s docs say it cannot be bundled without extra files, transports like pino-pretty included, and point to pino-webpack-plugin, esbuild-plugin-pino and bun-plugin-pino to generate them.
Does JavaScript have a Console log?
Yes: console.log exists in browsers and in Node, and in Node it prints to stdout with a newline. It works as the channel a line leaves through; the format is the weak part, since it prints whatever you pass, with no level, timestamp or fields of its own.
What is logger used for?
A logger records what an app did as entries with a time, a level and named fields, so anyone can filter them later by any of those. A print statement leaves text to read line by line. In a Node app that means one line per event, collected by the host and searchable in its log store.
If you have a working app built with these tools and need it ready for real customers, this is what we do.
Built it with AI. Now it has to hold up for real customers.
The Production Hardening Sprint takes the app you already have and builds the production foundation underneath it. Authentication and access rules, payments that stay consistent, error handling, monitoring, backups, automated tests and a documented handover. Our engineers work inside your existing codebase for ten working days. All 123 deliverables are included, and you get the evidence for each one.
See the Production Hardening Sprint →
$2,500 fixed price · 10 working days · One codebase