loglevel
loglevel is a very small wrapper around the console object that adds log levels and nothing else. You get log.trace, log.debug, log.info, log.warn, and log.error, plus setLevel to hide everything below a threshold and silent to turn it all off. Its defining trick is that enabled methods are bound directly to the underlying console methods rather than called through a wrapper, so the browser devtools still show the file and line number of your own code instead of a line inside the logging library. It also degrades gracefully: if console.debug does not exist it falls back to console.log, and if there is no console at all the calls become no-ops instead of throwing. Levels can persist to localStorage, so a developer can type log.setLevel('trace') in a production console and keep verbose logging across reloads. Named child loggers via getLogger let each module carry its own level.
For browser code that just needs level filtering without losing console line numbers, loglevel is still the right tool and almost nothing competes at 1.4 KB. On the server, or the moment you need formatting, context, or log shipping, move to pino or consola instead of stacking plugins onto a library that was designed not to do those things.
Use it if
- You are shipping browser code and want the console line numbers preserved, which almost every other logging library destroys by routing calls through a wrapper function
- You want production logs quiet by default but debuggable on demand: setLevel persists to localStorage so a developer or support engineer can turn on trace logging from the devtools console and have it survive reloads
- You are writing a library and want per-module loggers your consumers can turn up or down individually, without forcing a logging framework on them
- Bundle size is the constraint: it is a single file with zero dependencies that the README puts at 1.4 KB minified and gzipped
- You are logging on a Node server: there is no structured JSON output, no transports, no serializers, no redaction, and no async destination. pino exists precisely for this and writes newline-delimited JSON that your log aggregator can query
- You want timestamps, prefixes, colors, or context fields: none of that is built in, and adding it through methodFactory throws away the line-number preservation that was the reason to choose loglevel in the first place. The README says so directly
- You need logs shipped to a server: there is no built-in remote transport. The available options are third-party plugins such as loglevel-plugin-remote, which are separately maintained and much less used than loglevel itself
- You want an ESM package: it is a UMD bundle with one exported object, and the README documents that some toolchains need a default import while others need a namespace import. There are no named exports and nothing to tree-shake
- You want an actively developed dependency: 1.9.2 shipped in September 2024 and the repository was last pushed in March 2025 with 17 open issues (19 counting PRs). The library is arguably finished, but a single maintainer and a year without a release is a fact worth pricing in
Setup reality
npm install loglevel is as easy as it gets: no dependencies, no build step, TypeScript definitions included in the package. Three defaults reliably confuse people on the first day. The default level is warn, so log.info and log.debug print nothing at all and the library looks broken until you call enableAll or setLevel. setLevel persists to localStorage (falling back to cookies) unless you pass false as the second argument, which means a level someone set months ago in their browser silently overrides what your code asks for; setDefaultLevel is the call you actually want at startup because it only applies when nothing was persisted. Child loggers created with getLogger snapshot the root level at creation time, so changing the root level later does nothing to them until you call log.rebuild(). In ESM, the UMD shape means `import log from 'loglevel'` works in most bundlers while others need `import * as log`. There is no ambient global unless you load the dist file with a script tag, in which case it claims window.log and you may need noConflict.
Patterns
Log at each levelbasic-logging
import log from 'loglevel'
log.trace('entering handler')
log.debug('payload', payload)
log.info('user signed in')
log.warn('retrying request')
log.error('request failed', err)Only warn and error print out of the box because the default level is warn. log.log exists as an alias for log.debug so you can search and replace console.log without thinking.
Change the active levelset-level
import log from 'loglevel'
log.setLevel('debug') // string, case-insensitive
log.setLevel(log.levels.WARN) // enum, type-safe
log.setLevel(0) // 0 trace through 5 silentsetLevel persists the choice to localStorage, falling back to cookies. That is a feature in a browser console and a trap in application code, because it outlives the page load.
Set a level without overriding the developerdefault-level-at-startup
import log from 'loglevel'
log.setDefaultLevel(process.env.NODE_ENV === 'production' ? 'error' : 'debug')This is the call for application startup. It only applies when no level was persisted, so someone who ran setLevel('trace') in the console keeps their setting across reloads instead of having it stamped back to error.
Change level without writing to storagedisable-persistence
import log from 'loglevel'
log.setLevel('info', false) // second arg false skips localStorage
log.resetLevel() // back to the default and clears the stored valuePass false when the level comes from a config value or a feature flag; otherwise the first render permanently pins that user's log level. resetLevel is the way to clear a stale persisted level.
Give each module its own loggernamed-loggers
// checkout.js
import { getLogger } from 'loglevel'
const log = getLogger('checkout')
log.debug('cart recalculated')
// anywhere, including the devtools console
import rootLog from 'loglevel'
rootLog.getLogger('checkout').setLevel('trace')getLogger with the same name always returns the same instance, so calling it in every file is fine. Names must be non-empty strings or Symbols; Symbol-named loggers never persist their level.
Propagate a root level change to existing loggersrebuild-child-loggers
import log from 'loglevel'
const child = log.getLogger('billing') // inherits WARN at creation
log.setLevel('error')
child.getLevel() // still WARN
log.rebuild()
child.getLevel() // now ERRORChild loggers copy the root level only when they are first created. This is the single most reported surprise in the library, and rebuild() is the fix. Children that called setLevel themselves keep their own level.
Flip all logging on or offenable-or-silence-everything
import log from 'loglevel'
log.enableAll() // same as setLevel('trace')
log.disableAll() // same as setLevel('silent')Both persist like setLevel does. Handy to type into a browser console during an incident, less handy to leave in shipped code.
Skip work that only exists for a log lineguard-expensive-logging
import log from 'loglevel'
if (log.getLevel() <= log.levels.DEBUG) {
log.debug(buildExpensiveDiagnostic())
}Only worth it when profiling proved the payload construction is expensive. For plain string building, pass multiple arguments (log.debug('a', b, c)) instead; disabled methods are no-ops so nothing gets concatenated.
Add a prefix through methodFactoryprefix-plugin
import log from 'loglevel'
const originalFactory = log.methodFactory
log.methodFactory = function (methodName, logLevel, loggerName) {
const rawMethod = originalFactory(methodName, logLevel, loggerName)
return function (...args) {
rawMethod(`[${String(loggerName ?? 'root')}]`, ...args)
}
}
log.rebuild()You must call rebuild() or the new factory is never used. Wrapping in a function means the console now reports this file as the log site, losing the line-number advantage; that cost is the reason the plugin API is so minimal.
Forward errors to a collectorship-logs-to-a-server
import log from 'loglevel'
const originalFactory = log.methodFactory
log.methodFactory = function (methodName, logLevel, loggerName) {
const rawMethod = originalFactory(methodName, logLevel, loggerName)
return function (...args) {
rawMethod(...args)
if (methodName === 'error') {
navigator.sendBeacon('/logs', JSON.stringify({ logger: String(loggerName), args: args.map(String) }))
}
}
}
log.rebuild()There is no built-in transport, so this hand-rolled version or the loglevel-plugin-remote package is the whole story. Do not call fetch here without a guard: an error log inside your fetch error handler will loop.
Import it in ESM without interop errorsesm-import-interop
// works in most bundlers and Node ESM
import log from 'loglevel'
// some toolchains need the namespace form instead
import * as log from 'loglevel'The package is a single UMD file, and the README documents both forms because loaders disagree about what the default export is. If TypeScript complains about calling methods on a namespace import, switch to the default form.
Use the script tag build without stealing window.logavoid-global-conflict
<script src="https://unpkg.com/loglevel/dist/loglevel.min.js"></script>
<script>
var logging = log.noConflict();
logging.warn('now safe alongside other libraries');
</script>noConflict restores whatever window.log was before and hands you the logger. It only exists on the root logger, not on instances from getLogger.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pino | npm | You are logging server-side and need structured JSON, child bindings, redaction, and fast async transports |
| debug | npm | You want namespaced developer logging toggled by a DEBUG environment variable or localStorage key rather than numeric levels |
| consola | npm | You want pretty formatted output with tags, boxes, and reporters that works in both Node and the browser |
| loglevel-plugin-prefix | npm | You are staying on loglevel but need timestamps and logger names prefixed onto each message |