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.

The cost layers of one log statement A log statement may format a message string in Wasm, allocate for it, copy and decode it across the boundary into a JavaScript string, and call the console, which is slow especially with DevTools open. Compile-time levels remove all layers for disabled statements; buffering removes per-message boundary and console costs. formatting in Wasm allocation + fmt code boundary crossing copy + UTF-8 decode console call slow, worse with DevTools compile-time level filter removes all of the above buffered batch flush one crossing per batch

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.

Console call per message versus buffered binary records Calling the console for each message formats a string, crosses the boundary and calls a slow console API every time. Buffering fixed-size binary records in linear memory costs a few writes per message, and JavaScript drains and formats them in one batch per second. console per message format + allocate each time boundary crossing each time slow console call each time fine for rare logs buffered binary records a few memory writes one drain per second formatting in JS, off hot path hot paths

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.log in 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.

Slowdown from logging in a hot loop Percentage slowdown of a parsing benchmark with a log statement in its hot loop, using a console call per message, buffered binary records drained once per second, and logging compiled out. slowdown (%) console call per message 40 % buffered binary records 2 % compiled out 0 %

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.

← Back to Debugging & Profiling Wasm Modules