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-unknownwith 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.
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.
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::sleepin 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.
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.
Related
- Using Cargo features for Wasm and native builds — gating platform code.
- Fixing crates that fail to compile for Wasm — the compile-time counterpart.
- Handling panics in Rust Wasm — seeing the panic messages.
- Choosing a Rust Wasm target triple — browser versus WASI.
← Back to Rust to Wasm Compilation Guide