Logging from Rust Wasm to the Browser Console

This page answers one task: get diagnostic output from Rust code running as WebAssembly in a browser into the DevTools console — with log levels, structured fields and timing — and make sure it disappears from release builds.

Prerequisites

  • [ ] A Rust crate built for wasm32-unknown-unknown with wasm-bindgen.
  • [ ] Code that already uses the log macros (info!, debug!) or tracing, or the willingness to add them.
  • [ ] console_error_panic_hook installed, so panics are readable while you work.

Why println! shows nothing

On wasm32-unknown-unknown there is no standard output. println! and eprintln! compile, but the standard library writes to a stub that discards everything. There is no error and no warning — the text simply goes nowhere. The same is true for crates that log by writing to stderr. That surprises everyone exactly once.

The fix is to route output through JavaScript’s console object, which is what DevTools displays. You can call it directly with web_sys::console::log_1, but most Rust code — including your dependencies — logs through the log or tracing facades, which separate emitting a log record from deciding where it goes. Installing a Wasm-aware backend for the facade makes every existing log statement, yours and your dependencies’, appear in the console with the right level.

How a log! call reaches DevTools A Rust log macro creates a record through the log facade. The installed backend, console_log, formats it and calls the matching console method through web-sys, so it appears in DevTools with the right level and filter. log::info!(...) your code or a dependency log facade level check, record console_log backend formats the message web_sys::console info / warn / error DevTools console filterable by level

Step 1 — the quick direct call

For a one-off message while debugging, call the console directly. It needs the console feature of web-sys:

use web_sys::console;

console::log_1(&"parsed header".into());
console::log_2(&"record count:".into(), &(records.len() as u32).into());

The numbered variants take one to seven JsValue arguments, which DevTools displays as separate values — so numbers stay numbers and objects stay expandable. That is fine for a quick check and tedious for anything more.

Step 2 — install a backend for the log facade

console_log implements the log crate’s backend for the browser. Install it once, at startup:

[dependencies]
log = "0.4"
console_log = { version = "1", features = ["color"] }
console_error_panic_hook = "0.1"
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn start() {
    console_error_panic_hook::set_once();
    console_log::init_with_level(log::Level::Debug).expect("logger already set");
    log::info!("module started, version {}", env!("CARGO_PKG_VERSION"));
}

#[wasm_bindgen(start)] runs the function automatically when the module is instantiated, which is the right place for one-time setup. Each level maps to the matching console method — error! to console.error, warn! to console.warn, and so on — so DevTools’ level filter works as expected, and errors show with a red background and a stack trace.

Step 3 — use tracing for spans and timing

The tracing crate adds spans: named regions of execution with fields, which show what code was doing when a message was logged and how long it took. tracing-wasm maps spans onto the browser’s Performance API, so they appear in the Performance panel’s timeline as well as in the console.

[dependencies]
tracing = "0.1"
tracing-wasm = "0.2"
use tracing::{info, instrument};

#[wasm_bindgen(start)]
pub fn start() {
    console_error_panic_hook::set_once();
    tracing_wasm::set_as_global_default();
}

#[instrument(skip(pixels), fields(len = pixels.len()))]
pub fn apply_filter(pixels: &mut [u8], strength: f32) {
    info!("starting");
    // ... work ...
}
The log facade versus tracing in a Wasm module Both route Rust diagnostics to the browser console. log is smaller and simpler; tracing adds spans with fields and timing that tracing-wasm maps onto the Performance panel. log + console_log one record per message, with a level smallest backend, fewest dependencies used by most of the ecosystem messages and levels only tracing + tracing-wasm spans with fields and duration spans appear in the Performance panel also captures log records via a bridge when you need timing and context

Every call to apply_filter now creates a span carrying strength and the buffer length. In the Performance panel, under the Timings track, each call appears as a bar labelled with the function name, which makes it easy to see where time goes inside a long Wasm task without a full profiler. The profiler is still the right tool for function-level detail; see profiling Wasm with the Chrome Performance panel.

Step 4 — strip logging from release builds

Logging costs size and time even when nothing is printed. Formatting machinery for the messages is compiled in, and every call checks the current level at runtime. The log crate can remove statements below a level at compile time:

[dependencies]
log = { version = "0.4", features = ["release_max_level_warn"] }

With that feature, debug! and info! calls compile to nothing in release builds; only warn! and error! remain. tracing has the same features (release_max_level_warn, max_level_off). For a module where every byte counts, max_level_off in release removes logging entirely — including the backend, if nothing references it.

Going further, make the backend itself a feature so production builds do not even link it:

[features]
default = []
console-logging = ["dep:console_log"]

[dependencies]
console_log = { version = "1", optional = true }
#[cfg(feature = "console-logging")]
console_log::init_with_level(log::Level::Debug).unwrap();

Build with --features console-logging in development and without it in release.

Release module size by logging configuration The same crate built in release mode with four logging setups. Leaving debug-level logging compiled in costs the most, because each message's formatting code is retained. release .wasm after wasm-opt -Oz (KB) console_log, all levels 312 KB release_max_level_warn 251 KB max_level_off 219 KB max_level_off, backend not linked 214 KB

Step 5 — log what helps, in the format that helps

A few habits make Wasm logs more useful. Log at the boundary: one line when a JavaScript call enters the module with the argument sizes, one when it leaves with the result — that pinpoints which call misbehaved. Include sizes and counts rather than whole buffers; DevTools will happily try to render a 50 MB array. Use structured fields with tracing so you can filter on them. And never log secrets or user data from a module that processes them, because console output is easy to capture in support screenshots and error-reporting tools.

For production diagnostics, console output is the wrong channel — nobody watches users’ consoles. Ship errors to an error tracker instead, as described in reporting Wasm crashes to an error tracker.

Grouping output from one operation

When a single JavaScript call triggers many log lines — parsing a file, rendering a frame — the console quickly becomes a wall of text. The console’s grouping methods help: console.group and console.groupEnd fold related lines under one collapsible heading. They are available through web-sys as well, and a small guard type makes the grouping automatic, closing the group when the operation’s scope ends even on an early return:

struct ConsoleGroup;
impl ConsoleGroup {
    fn new(label: &str) -> Self { web_sys::console::group_1(&label.into()); ConsoleGroup }
}
impl Drop for ConsoleGroup {
    fn drop(&mut self) { web_sys::console::group_end(); }
}

pub fn import_file(bytes: &[u8]) -> Result<Doc, Error> {
    let _g = ConsoleGroup::new(&format!("import {} bytes", bytes.len()));
    log::debug!("detecting format");
    // ... every log line inside appears nested under the group ...
    Ok(doc)
}

Keep this behind the same development feature as the logger, so release builds pay nothing for it.

Expected output

With console_log at debug level, the console shows coloured, levelled lines with the module path:

INFO  app::start         module started, version 0.4.2
DEBUG app::parser        header: 3 sections, 1832 bytes
WARN  app::parser        unknown section id 0x7f skipped
ERROR app::render        texture upload failed: OutOfMemory

With tracing-wasm, the Performance panel’s Timings track shows a bar per instrumented call, named after the function.

Gotchas

  • Nothing is printed. The logger was never initialised, or was initialised after the code that logs. Initialise in the start function.
  • logger already set panic. Two crates both try to install a global logger — often your crate and a framework that installs its own. Initialise once, or use try_init and ignore the error.
  • Messages from dependencies flood the console. Some crates log heavily at debug level. Set the global level to Info and raise it for your own modules only, or filter dependencies out with a tracing directive.
  • Logs slow down a hot loop dramatically. A debug! inside a per-pixel loop formats strings even when filtered at runtime in some setups. Use compile-time level filtering, and keep logging out of inner loops.
  • Panic messages are just “unreachable”. console_error_panic_hook was not installed, or panic = "abort" removed the message. See handling panics in Rust Wasm.

Performance note

In the example crate, debug-level logging compiled in but filtered out at runtime added 61 KB to the release module and slowed a parsing benchmark by 4% from level checks alone. Compile-time filtering with max_level_off removed both costs. A single info! per call at the boundary, kept in release builds, cost 0.3 µs per call — negligible for calls that do real work.

Frequently Asked Questions

Does log or tracing work in a Web Worker? Yes. console exists in workers, and DevTools shows worker messages with the worker’s name in the console’s context selector.

Can I log Rust structs as expandable objects? Convert them to JsValue with serde-wasm-bindgen and pass them to console::log_1. DevTools then renders them as JavaScript objects. See serializing data with serde-wasm-bindgen.

What about WASI targets? On wasm32-wasip1, println! and eprintln! work, and env_logger writes to stderr as on native targets.

Can I filter logs per module at runtime? console_log uses a single global level. For per-module filtering, tracing-subscriber with an EnvFilter-style directive string works in Wasm too: read the directive from a URL parameter or localStorage at startup, so you can turn on app::parser=debug in a deployed build without rebuilding — assuming those levels were not compiled out.

Is console.log slow? Each call crosses the boundary and DevTools formats the arguments, which adds up only in very hot code. With DevTools closed, console calls are cheap but not free.

← Back to Rust to Wasm Compilation Guide