Logging from Wasm Without Slowing It Down
This page answers one task: a WebAssembly module needs logging — for debugging, for diagnosing field issues — but adding log statements in hot paths made it noticeably slower, and removing them leaves you blind. You want logging whose cost is near zero when disabled and small when enabled, without losing the messages you need.
Prerequisites
- [ ] A Wasm module (Rust with
log/tracing, or C/C++ with a logging macro). - [ ] A JavaScript side that receives log output (console, a log collector).
- [ ] A benchmark for the module’s hot paths.
Where logging cost comes from
A log statement in Wasm can cost much more than the same statement natively. Formatting builds a string in linear memory — allocations and formatting code
for every call. Crossing the boundary copies that string out and decodes UTF-8 into a JavaScript string. Console calls are slow in browsers, especially with
DevTools open, where each message is serialised for display. Together, a single console.log from inside a loop can cost tens of microseconds — thousands of
times the cost of the code being logged. And formatting machinery adds kilobytes to the module even when logs are disabled at runtime, if the code is still
compiled in.
The remedies attack each cost: decide at compile time which levels exist, avoid formatting until a record is actually emitted, batch records instead of crossing the boundary per message, and send structured data rather than strings.
Step 1 — compile out levels you do not need
In Rust, the log crate’s max_level_* and release_max_level_* features remove log statements below a level at compile time, including their formatting
code:
[dependencies]
log = { version = "0.4", features = ["max_level_debug", "release_max_level_warn"] }
tracing has equivalent static level features. In C/C++, wrap logging in macros that expand to nothing below a compile-time level. In release builds, keep only
warnings and errors compiled in; that removes both the runtime cost and the code size of debug logging.
Step 2 — check the level before formatting
For levels that remain compiled in but are usually disabled at runtime, make sure formatting happens only when a record is emitted. The log macros already do
this (log::debug! checks the level before formatting arguments); hand-written macros should too. Avoid patterns like log(&format!(...)) that format
unconditionally.
Step 3 — buffer records in linear memory and flush in batches
Instead of crossing into JavaScript for each message, append records to a buffer in linear memory and let JavaScript drain it periodically:
use std::cell::RefCell;
thread_local! { static LOG_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(64 * 1024)); }
pub fn emit(level: u8, code: u16, a: u32, b: u32) {
LOG_BUF.with(|b_| {
let mut buf = b_.borrow_mut();
if buf.len() + 11 > buf.capacity() { return; } // drop when full; count drops elsewhere
buf.push(level); buf.extend_from_slice(&code.to_le_bytes());
buf.extend_from_slice(&a.to_le_bytes()); buf.extend_from_slice(&b.to_le_bytes());
});
}
#[wasm_bindgen]
pub fn drain_logs() -> Vec<u8> { LOG_BUF.with(|b| std::mem::take(&mut *b.borrow_mut())) }
setInterval(() => {
const bytes = wasm.drain_logs();
for (const rec of decodeRecords(bytes)) console.debug(formatRecord(rec)); // formatting happens in JS, off the hot path
}, 1000);
Records here are fixed-size binary entries — a level, a message code and two numeric arguments — so emitting one is a few memory writes. Message text lives in a table on the JavaScript side, keyed by code. Flushing once a second turns thousands of boundary crossings into one.
Step 4 — rate-limit and summarise
Some messages fire millions of times (“cache miss”, “clamped value”). Instead of logging each, count them and log a summary per flush: “cache miss ×48,213”. Per-code counters in linear memory make this nearly free. Rate-limit noisy warnings so a failure loop cannot flood the console or a log collector.
Step 5 — measure the overhead
Benchmark hot paths with logging compiled out, compiled in but disabled, and enabled with buffering. Disabled-but-compiled-in should be within noise of compiled out; enabled buffering should cost a small percentage. If not, a log statement is formatting unconditionally or sits in an inner loop where even a few writes matter — move it out of the loop or count instead of logging.
Structured logging for production
For production diagnostics, emit structured records (code, level, numeric fields, a timestamp from an imported clock) rather than prose. Structured records are smaller, cheaper to emit, easier to aggregate in a log backend, and do not leak user content through formatted strings. Map codes to human-readable messages in the viewer.
Logs that survive a trap
The most valuable log messages are the ones written just before a crash — and a buffered design can lose them, because a trap aborts the instance before
JavaScript drains the buffer. Two techniques keep them. First, drain in the error path: when the wrapper catches a RuntimeError from the module, read the
log buffer from linear memory directly (the memory object survives the trap, even if the instance should not be reused) and include its contents in the error
report. Because the buffer lives at a known address — export a function returning its pointer and length at startup, or a global — the wrapper can read it
without calling into the trapped instance. Second, for code paths known to be risky, emit an immediate, unbuffered message at error level before the risky
operation; error-level messages are rare enough that their cost does not matter.
Correlating logs with the host
Logs from Wasm are most useful alongside what the host was doing. Include a monotonically increasing sequence number and a timestamp from an imported clock in each record, and have the JavaScript side tag drained records with the current request, user action or document ID before sending them on. In server hosts, attach the trace or request ID that the host already uses, so module logs line up with HTTP logs and traces. Without correlation, a log line like “decode failed, code 7” is hard to connect to the request that caused it.
Choosing what to log
Log state transitions and decisions (cache rebuilt, fallback path chosen, limit reached), not every iteration. Logs that describe why the module did something are far more useful than logs that echo inputs, and they are naturally rare.
A message catalogue
Binary records need a catalogue that maps codes to messages and argument meanings. Generate it from one source — a list in the Rust or C code with code, level and template — into both the module (as constants) and a JSON file the JavaScript viewer loads, so codes never drift between the two sides.
Expected output
Release builds contain only warn and error statements; debug logging compiles out entirely and the module is 18 KB smaller; enabled diagnostic logging writes 11-byte binary records into a 64 KB buffer drained once a second; a hot loop that previously slowed by 40% with logging enabled now slows by 2%; and noisy events appear as per-second counts.
Gotchas
- Formatting before checking the level. Cost paid even when disabled. Check first.
console.login hot loops. Tens of microseconds each. Buffer and batch.- Leaving debug levels compiled into release. Size and cost. Use static level features.
- Unbounded log buffers. Memory grows. Cap and count drops.
- Logging user content. Privacy risk. Log codes and numbers.
- Losing the last logs before a crash. Buffers die with the instance. Read them from memory in the error handler.
Performance note
In a parsing benchmark, per-message console.log from a hot loop slowed the module by 40% with DevTools closed; buffered binary records added 2%; compiled-out
logging added nothing measurable.
Frequently Asked Questions
Does DevTools being open matter? Yes — console calls are much slower with DevTools open, which can make debugging sessions misleadingly slow.
Can I use tracing spans in Wasm?
Yes, with a subscriber that buffers events; avoid per-span console output in hot paths.
How do logs reach a server? Drain the buffer and send batches with the rest of your telemetry, respecting sampling and consent.
Is println! usable?
On wasm32-unknown-unknown it writes nowhere; use a logger that targets the console or a buffer.
How do I keep buffered logs when the module traps? Read the buffer from linear memory in the wrapper’s error handler using its exported address, and include it in the error report.
How do I connect module logs to a request? Add sequence numbers and timestamps to records and tag drained records with the host’s request or trace ID.
What should be logged at all? Decisions and state transitions — fallbacks taken, limits reached — rather than every iteration or input.
Should error-level messages be buffered? Not necessarily — they are rare, so emitting them immediately is affordable and keeps them even if a trap follows.
Related
- Logging from Rust Wasm to the browser console — console setup.
- Using printf debugging in Wasm — quick output.
- Emitting structured logs from server-side Wasm — servers.
- Removing panic and formatting bloat from Rust Wasm — size.
← Back to Debugging & Profiling Wasm Modules