Using std Time and Threads in Rust Wasm

This page answers one task: Rust code that works natively compiles cleanly for wasm32-unknown-unknown, then panics in the browser with messages such as “time not implemented on this platform” or “operation not supported on this platform” — and you want to know which standard-library APIs are affected and what to use instead.

Prerequisites

  • [ ] A Rust crate compiled for wasm32-unknown-unknown with wasm-bindgen.
  • [ ] Panic messages visible in the console (console_error_panic_hook).
  • [ ] Optionally, a WASI target for comparison.

Why std compiles but does not work

The wasm32-unknown-unknown target has no operating system: no clock, no threads, no file system, no environment variables. Rather than removing those APIs from std — which would break compilation of most crates — the target ships stub implementations that compile and then fail at runtime. Some panic (Instant::now(), SystemTime::now(), thread::spawn), some return errors (std::fs::File::open returns Unsupported), and some silently do nothing useful (std::env::var returns Err(NotPresent); println! writes nowhere).

The result is the most confusing class of Wasm bug: code that type-checks and links, works in native tests, and fails only when a particular path runs in the browser — often deep inside a dependency that calls Instant::now() for a timeout or a metric. The wasm32-wasip1 target behaves differently: it has a clock, a file system (through preopened directories) and environment variables via WASI, but still no threads unless built for wasm32-wasip1-threads.

std APIs on Wasm targets On wasm32-unknown-unknown, Instant and SystemTime panic, thread spawn panics, sleep panics, file access returns Unsupported and env vars are absent. On wasm32-wasip1, time, files and env work through WASI, but threads are unavailable unless the threads target is used. API wasm32-unknown-unknown wasm32-wasip1 Instant::now, SystemTime::now panics works thread::spawn panics panics (no threads) thread::sleep panics works (blocks) std::fs returns Unsupported works with preopens std::env::var always NotPresent works Mutex, RwLock, atomics work (single thread) work

Step 1 — replace Instant and SystemTime

For your own code, use web-time, a drop-in crate that re-exports std::time natively and implements Instant and SystemTime on top of performance.now() and Date.now() in the browser:

[dependencies]
web-time = "1"
use web_time::{Instant, SystemTime, UNIX_EPOCH};

let start = Instant::now();
expensive();
log::info!("took {:?}", start.elapsed());
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis();

The types match std::time’s API, so swapping the import is usually the whole change. Alternatives are instant (older, now superseded) and calling js_sys::Date::now() or web_sys::Performance::now() directly behind a cfg.

Step 2 — find dependencies that call std::time

A panic from inside a dependency needs the dependency to cooperate. Search the dependency tree for direct uses, and check for Wasm-aware features:

cargo tree --target wasm32-unknown-unknown -e normal | less
grep -rn "Instant::now\|SystemTime::now" ~/.cargo/registry/src/*/<crate>-*/src | head

Many crates offer a feature that routes time through web-time or js-sys (often named wasm-bindgen or js); others accept a clock parameter. getrandom has the same problem for randomness and needs its js feature (or the wasm_js backend in newer versions) on this target. If a crate offers no option, a patch with [patch.crates-io] or an issue upstream is the remaining route.

Step 3 — replace sleep and timers with async

thread::sleep cannot work in the browser’s main thread — blocking it would freeze the page — so it panics. Replace sleeps with async timers, driven by the browser’s event loop:

use gloo_timers::future::TimeoutFuture;
use wasm_bindgen_futures::spawn_local;

spawn_local(async {
    TimeoutFuture::new(500).await;      // non-blocking 500 ms delay
    retry_request().await;
});

Code that polls in a loop with sleeps between iterations becomes an async loop with timers, or a setInterval callback. Rate limiters and retry policies in dependencies often need an async-timer feature for Wasm; async runtimes such as tokio support only a subset on wasm32 (rt without rt-multi-thread, no time driver in the browser), so check each crate’s Wasm notes.

Blocking versus async waiting in browser Wasm thread sleep blocks the calling thread, which on the browser main thread would freeze the page, so it panics on wasm32-unknown-unknown. An async timer yields to the event loop and resumes the Rust future when the browser fires the timeout. thread::sleep(500ms) blocks the thread would freeze the page panics on this target not available TimeoutFuture::new(500).await yields to the event loop page stays responsive resumes on timeout use in the browser

Step 4 — replace thread::spawn with workers or rayon

thread::spawn panics because the target has no threads by default. Parallelism in the browser comes from Web Workers. For data parallelism, build with the atomics target features and use wasm-bindgen-rayon, which runs a rayon thread pool on workers sharing one memory; this needs cross-origin isolation and nightly build flags, as described in building Rust Wasm with threads using rayon. For background tasks that need no shared memory, run a separate module instance in a worker and communicate with messages.

#[cfg(not(target_arch = "wasm32"))]
fn process(items: &[Item]) -> Vec<Out> { items.par_iter().map(work).collect() }
#[cfg(target_arch = "wasm32")]
fn process(items: &[Item]) -> Vec<Out> { items.iter().map(work).collect() }   // or rayon via wasm-bindgen-rayon

Step 5 — handle fs, env and stdout

The file system and environment variables have no browser equivalent; design APIs to take data and configuration as parameters instead of reading them. For output, println! goes nowhere on wasm32-unknown-unknown — use console_log or tracing-wasm to send logs to the console, as in logging from Rust Wasm to the browser console. Persistent storage comes from browser APIs — IndexedDB, OPFS — through web-sys or wrapper crates.

Mutexes and atomics on a single thread

Mutex, RwLock, Once and atomics compile and work on wasm32-unknown-unknown without the atomics feature, because there is only one thread: locking never contends. A Mutex that is locked twice on the same thread — re-entrantly, for example through a callback that calls back into Rust while a lock is held — deadlocks natively but on this target panics or aborts, which surfaces as a trap. With the atomics target feature enabled, real atomics are used, and blocking waits are not allowed on the browser main thread: Atomics.wait throws there, so a contended lock on the main thread fails. Keep locks on the main thread short and uncontended, or move contended work to workers.

Catching these problems before the browser

Runtime-only failures deserve compile-time help. Clippy’s disallowed_methods lint can ban std::time::Instant::now, std::thread::spawn and std::thread::sleep in crates compiled for Wasm:

# clippy.toml
disallowed-methods = [
  { path = "std::time::Instant::now", reason = "panics on wasm32; use web_time::Instant" },
  { path = "std::thread::sleep", reason = "panics on wasm32; use an async timer" },
]

It does not see inside dependencies, so complement it with tests that exercise the Wasm build in a headless browser, where any panic fails the test. Grepping the final module’s name section for std::time symbols is a crude but effective check that no dependency still calls the stub.

WASI targets are different

On wasm32-wasip1, Instant, SystemTime, thread::sleep, std::fs (inside preopened directories) and std::env work through WASI calls, so much native code runs unchanged in Wasmtime or Node’s WASI. Threads remain unavailable on that target; wasm32-wasip1-threads adds them where the runtime supports the threads proposal. If code must run in both the browser and WASI, gate on target_os = "unknown" versus target_os = "wasi" rather than on target_arch.

Randomness, hashing and other hidden OS calls

Time and threads are the obvious gaps; a few less obvious ones produce the same symptoms. HashMap’s default hasher seeds itself from the operating system’s random source, which on wasm32-unknown-unknown is a fixed fallback — maps work, but iteration order is deterministic across runs, which can hide order-dependence bugs in tests and expose hash-flooding concerns for maps keyed by untrusted input. Crates that need real randomness call getrandom, which fails to build or panics on this target until its JavaScript backend is selected, and then uses crypto.getRandomValues. std::process::id, std::net and std::io::stdin have no implementation either. A useful habit when adopting a new dependency for a browser build is to search its source for std::time, std::thread, std::fs, std::net and getrandom before writing integration code, and to read its documentation for a “WebAssembly” or “no_std” section that names the feature to enable.

Designing crates that behave on every target

The durable fix is to make platform services explicit inputs. A library that needs the current time can take a now: impl Fn() -> Instant parameter or a Clock trait; one that needs randomness can take an RngCore; one that needs storage can take a trait object for reads and writes. Native callers pass the system implementations, browser callers pass web-time, getrandom and an IndexedDB-backed store, and tests pass deterministic fakes — which makes time-dependent logic testable on every target, not only portable.

Expected output

The crate uses web_time::Instant for timing, gloo-timers for delays, getrandom with its JavaScript backend, console_log for output and no thread::spawn on wasm32; Clippy flags any new Instant::now call; and a headless browser test runs the timing and retry paths without panics.

Gotchas

  • Native tests pass, browser panics. The stubs only fail at runtime. Test the Wasm build in a browser.
  • Dependencies calling Instant::now. Enable their Wasm features or patch them.
  • thread::sleep in retry loops. Use async timers.
  • Forgetting getrandom’s JavaScript backend. Randomness fails to compile or panics.
  • println! debugging. Output disappears. Log to the console.
  • Re-entrant locks through callbacks. A second lock on the same thread traps. Keep locks short and never hold them across calls into JavaScript.

Performance note

web_time::Instant::now() costs one call into performance.now(), about 30–60 ns in current engines — negligible for timing operations but noticeable in tight loops that time every iteration. Hoisting the timing out of the loop removed a 4% overhead in a profiled hot path.

Overhead of timing every iteration of a hot loop Relative run time of a hot loop that calls Instant now on every iteration compared with timing the loop once from outside. relative run time timing each iteration 1.0 x timing the whole loop 1 x

Frequently Asked Questions

Why does the target compile code that cannot run? To keep the ecosystem compiling; removing APIs would break most crates. The trade-off is runtime panics.

Is web-time the same as std::time natively? Yes — it re-exports std::time on non-Wasm targets.

Can I block in a worker? Yes, Atomics.wait is allowed in workers, so blocking synchronisation works there with the atomics feature.

Does SystemTime respect the user’s clock? It reads Date.now(), which can jump when the user’s clock changes; use Instant for durations.

What about std::process? There are no processes on either target; process::exit on WASI ends the instance.

Why is my HashMap iteration order the same on every page load? The default hasher’s seed falls back to a fixed value on this target; do not rely on order, and use a keyed hasher for untrusted keys.

← Back to Rust to Wasm Compilation Guide