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, time or jiff.

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.

Representations that cross the boundary safely Instants cross as epoch milliseconds in an f64 or epoch nanoseconds as a BigInt. Calendar dates cross as year, month and day numbers or an ISO date string with no time zone. Durations cross as a number with an explicit unit. JavaScript Date objects should be converted at the edge rather than passed through. kind of value boundary type example instant (ms precision) f64 epoch ms 1710079500000 instant (ns precision) BigInt epoch ns 1710079500000123456n calendar date {year, month, day} or "2024-03-10" no time zone local date-time + zone ISO string + IANA zone "09:00", Europe/Oslo duration number + unit in the name durationMs: 92500

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.

How a calendar date gets shifted through Date The user picks 10 March in a date input. JavaScript creates a Date at local midnight, which in UTC minus 8 is 08:00 UTC. The module converts to UTC and formats the date, which still reads 10 March, but a user in UTC plus 9 gets 9 March at 15:00 UTC, and the module reports the 9th. user picks 10 March date input new Date(local midnight) an instant in UTC+9 → 9 Mar 15:00Z previous day in UTC module formats in UTC reads 9 March off-by-one date zone-dependent bug

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. Use BigInt/i64.
  • UTC logic for local questions. “Weekend” and “today” depend on a zone. Pass it.
  • Truncating negative timestamps. as i64 rounds 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.

Per-value cost of passing a timestamp Nanoseconds per value for passing a timestamp as an f64, as a js_sys Date object, and as an ISO 8601 string parsed inside the module. ns per value f64 epoch ms 2 ns js_sys::Date 20 ns ISO string parsed in Wasm 150 ns

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.

← Back to Passing Complex Types Across the Boundary