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-unknownwith wasm-bindgen. - [ ] Code that already uses the
logmacros (info!,debug!) ortracing, or the willingness to add them. - [ ]
console_error_panic_hookinstalled, 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.
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 ...
}
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.
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
startfunction. logger already setpanic. Two crates both try to install a global logger — often your crate and a framework that installs its own. Initialise once, or usetry_initand ignore the error.- Messages from dependencies flood the console. Some crates log heavily at debug level. Set the global level to
Infoand raise it for your own modules only, or filter dependencies out with atracingdirective. - 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_hookwas not installed, orpanic = "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.
Related
- Handling panics in Rust Wasm — the error half of diagnostics.
- Calling Web APIs from Rust with wasm-bindgen — how
web_sys::consoleis bound. - Emitting structured logs from server-side Wasm — the server counterpart.
- Removing panic and formatting bloat from Rust Wasm — why log formatting costs bytes.
← Back to Rust to Wasm Compilation Guide