Passing Dates and Timestamps Across the Boundary
This page answers one task: a WebAssembly module works with dates and times — scheduling, logs, financial records, media timestamps — and values passed between it and JavaScript come out shifted by hours, truncated to seconds or rounded in the wrong place. You want representations that cross the boundary exactly and an API that makes the right one obvious.
Prerequisites
- [ ] A module (usually Rust with wasm-bindgen, or C/C++) that handles dates or timestamps.
- [ ] Clarity about what the values mean: instants in time, calendar dates, or durations.
- [ ] For Rust, familiarity with
chrono,timeorjiff.
Instants, calendar dates and durations are different things
Most date bugs come from treating three different kinds of value as one. An instant is a point on the global timeline — “the payment was made at
2024-03-10T14:05:00Z” — the same everywhere regardless of time zone. A calendar date or local date-time is a value on a calendar without a time zone —
“the user’s birthday is 10 March”, “the shop opens at 09:00” — which corresponds to different instants in different zones. A duration is an amount of
time — “the clip is 92.5 seconds long”. JavaScript’s Date represents only instants (milliseconds since the Unix epoch), which is why passing a calendar
date through Date shifts it by the local offset. Choose the representation by the kind of value, not by what is convenient.
At the boundary, WebAssembly has numbers, and JavaScript has Date, numbers, BigInt and strings. The safest representations are numbers for instants
and durations (with an explicit unit) and structured values or ISO strings for calendar dates.
Step 1 — pass instants as epoch milliseconds
Epoch milliseconds in an f64 are exact for every millisecond within hundreds of thousands of years, are what Date.now() and date.getTime() return,
and cross the boundary as a plain number:
use wasm_bindgen::prelude::*;
use chrono::{DateTime, Datelike, TimeZone, Utc};
#[wasm_bindgen]
pub fn next_billing_ms(last_billed_ms: f64) -> f64 {
let last: DateTime<Utc> = Utc.timestamp_millis_opt(last_billed_ms as i64).single().expect("valid instant");
let next = last + chrono::Months::new(1);
next.timestamp_millis() as f64
}
const next = new Date(next_billing_ms(lastBilled.getTime()));
Name parameters with their unit (_ms, _secs) so the API cannot be misread. Convert to Date only at the edge, in JavaScript, for display.
Step 2 — use BigInt for nanoseconds
Media timestamps, tracing spans and database timestamps often need microseconds or nanoseconds. Nanoseconds since the epoch exceed Number’s exact
integer range (2^53), so pass them as i64/u64, which wasm-bindgen maps to BigInt:
#[wasm_bindgen]
pub fn span_duration_ns(start_ns: i64, end_ns: i64) -> i64 { end_ns - start_ns }
const ns = span_duration_ns(1710079500000123456n, 1710079500004123456n); // 4000000n
BigInt arithmetic in JavaScript is slower than Number arithmetic and does not mix with numbers without explicit conversion; keep nanosecond values
inside the module where possible and convert to milliseconds for UI. See
passing 64-bit integers with BigInt.
Step 3 — accept a JavaScript Date when it is convenient
wasm-bindgen can take a js_sys::Date directly, which is convenient for APIs called with Date objects; convert immediately:
#[wasm_bindgen]
pub fn is_weekend(date: &js_sys::Date) -> bool {
let ms = date.get_time();
if ms.is_nan() { return false; } // Invalid Date
let dt = Utc.timestamp_millis_opt(ms as i64).unwrap();
matches!(dt.weekday(), chrono::Weekday::Sat | chrono::Weekday::Sun)
}
Note the subtle bug in this example: it decides “weekend” in UTC, not in the user’s time zone. A Saturday evening in California is already Sunday in UTC. Which zone matters is a product decision, and the API should take the zone explicitly when it matters.
Step 4 — pass calendar dates without time zones
For birthdays, due dates and other calendar values, never pass a Date. Pass the components or an ISO date string, and use a calendar-date type on the
Rust side:
use chrono::NaiveDate;
#[wasm_bindgen]
pub fn days_until(from: &str, to: &str) -> Result<i32, JsError> {
let a = NaiveDate::parse_from_str(from, "%Y-%m-%d").map_err(|e| JsError::new(&e.to_string()))?;
let b = NaiveDate::parse_from_str(to, "%Y-%m-%d").map_err(|e| JsError::new(&e.to_string()))?;
Ok((b - a).num_days() as i32)
}
days_until("2024-03-10", "2024-12-25"); // the strings a date input already produces
The Temporal API’s Temporal.PlainDate (available in recent browsers) serialises to exactly this format, which makes it a natural partner for such
APIs; toString() gives "2024-03-10".
Step 5 — handle time zones explicitly
When the module must compute in a user’s local time — “every weekday at 09:00 Oslo time” — pass the IANA zone name and do the conversion where a time-zone
database is available. Either bundle zone data into the module (chrono-tz or jiff with bundled data, adding a few hundred kilobytes) or do zone
conversion in JavaScript, where Intl and Temporal have the browser’s own database, and pass the module UTC instants plus offsets. Keeping zone logic
in JavaScript avoids shipping and updating a second database; keeping it in the module makes the module self-contained and testable natively.
Serialising dates inside structures
When dates travel inside structs through serde-wasm-bindgen or JSON, pick a serialisation per field and make it explicit: chrono’s serde support can
serialise as RFC 3339 strings or as epoch milliseconds (chrono::serde::ts_milliseconds). Strings are self-describing and readable in logs; numbers are
smaller and faster. Avoid relying on default serialisations that differ between crates or versions. On the JavaScript side, parse RFC 3339 strings with
new Date(str) for instants or Temporal.Instant.from(str) for exactness.
Precision and rounding traps
Date stores milliseconds; anything finer is truncated when values pass through it. performance.now() gives fractional milliseconds relative to page
load, not the epoch, so it cannot be combined with epoch timestamps without adding performance.timeOrigin. Converting seconds as f64 to integer
milliseconds with as i64 truncates toward zero, which shifts negative timestamps (before 1970) the wrong way; use .round() or .floor() deliberately.
And f64 epoch nanoseconds lose precision: 1.7e18 is beyond 2^53, so nanosecond values in f64 are only accurate to about 256 ns.
Testing date code across time zones
Date bugs hide because developers’ machines sit in one zone. Run the module’s tests under several zones: natively, set the TZ environment variable for
cargo test (most date libraries read it for local time); in Node-based JavaScript tests, set TZ before starting Node, since it is read at startup;
in browser tests, Playwright’s timezoneId context option runs the page in a chosen zone. Pick zones that expose different failure modes — a large
positive offset (Pacific/Auckland), a large negative one (America/Los_Angeles), one with a half-hour offset (Asia/Kolkata) and one observing
daylight-saving transitions — and include test dates on and around the transitions, where a day is 23 or 25 hours long and naive “add 24 hours” logic
lands on the wrong calendar day. A small matrix of zones in CI catches almost every zone bug before users in those zones do.
Designing the API surface
Make the representation part of the type signature where you can. In TypeScript declarations, use branded types (EpochMs, IsoDate) or Temporal types
so a calendar date cannot be passed where an instant is expected. On the Rust side, accept newtype wrappers rather than bare f64 and String, and
convert at the wasm-bindgen boundary in one place. Document each function’s time semantics in one sentence — “takes a UTC instant in epoch milliseconds”,
“takes a calendar date with no time zone” — because the most damaging date bugs come from two developers understanding the same parameter differently.
Expected output
Instants cross as f64 milliseconds with unit-suffixed parameter names; tracing spans use BigInt nanoseconds; calendar dates cross as "YYYY-MM-DD"
strings parsed into NaiveDate; the weekend check takes an explicit zone; a test suite running with TZ=Pacific/Auckland and TZ=America/Los_Angeles
passes; and no Date objects are stored inside the module.
Gotchas
- Calendar dates through
Date. Shifted by the local offset. Pass strings or components. - Unitless numbers. Seconds and milliseconds get mixed. Put the unit in the name.
- Nanoseconds in
f64. Precision lost. UseBigInt/i64. - UTC logic for local questions. “Weekend” and “today” depend on a zone. Pass it.
- Truncating negative timestamps.
as i64rounds toward zero. Floor deliberately.
Performance note
Passing an f64 timestamp costs nothing beyond the call; passing a js_sys::Date adds a reference and a getTime call (about 20 ns); parsing an ISO
string inside the module costs about 150 ns — all negligible except in loops over large arrays, where typed arrays of epoch milliseconds are best.
Frequently Asked Questions
Should I use Temporal? Where available, yes, on the JavaScript side; its types map cleanly to instants, plain dates and zoned date-times.
Is SystemTime usable inside the module?
On wasm32-unknown-unknown it panics; get the current time from JavaScript or web-time.
How do I pass arrays of timestamps?
As a Float64Array of epoch milliseconds, or BigInt64Array for nanoseconds.
What about leap seconds? Neither JavaScript nor common Rust crates model them for epoch arithmetic; they are ignored.
How do I test date logic in other time zones?
Set TZ for native and Node tests and Playwright’s timezoneId for browser tests, with dates around daylight-saving transitions.
Related
- Passing 64-bit integers with BigInt — nanosecond values.
- Serializing data with serde-wasm-bindgen — dates inside structs.
- Using std time and threads in Rust Wasm — getting the current time.
- Reading the clock and randomness in WASI — time outside the browser.