Skip to content
Node.js

Temporal in Node.js 26: what happens when a date leaves your process

Temporal is on by default in Node.js 26. At JSON, BullMQ, worker-thread and database boundaries it becomes a string, an error, or possibly nothing.

K6 min read
a wall full of clocks

Node.js 26 enables the Temporal API by default, with no flag1. Node.js 26 is due to enter long-term support this month2, so Temporal is about to reach production services that never had it before. Inside a process it is a clear improvement on Date: immutable values, explicit time zones, and separate types for an instant, a calendar date and a duration.

The trouble starts at the edges. Every time a Temporal value leaves the process — in a JSON response, a queued job, a message to a worker thread or a database write — it turns into something else. Sometimes that is a string, sometimes an exception, and in one case possibly an empty object. This article goes through each boundary, then gives you one small module that handles all of them.

Three Date habits that now throw

Temporal values refuse to turn into primitives. valueOf() always throws a TypeError, so >, < and subtraction fail instead of quietly comparing strings3. That breaks three habits carried over from Date:

habits.mjsJavaScript
const a = Temporal.Instant.from("2026-10-05T09:00:00Z");const b = Temporal.Instant.from("2026-10-05T10:00:00Z"); // Each of these throws a TypeError://   a < b//   [b, a].sort((x, y) => x - y)//   new Date(a) console.log(Temporal.Instant.compare(a, b));               // -1console.log([b, a].sort(Temporal.Instant.compare)[0].toString());console.log(new Date(a.epochMilliseconds).toISOString()); const seen = new Set([a, Temporal.Instant.from(a.toString())]);console.log(seen.size); // 2: the same instant, as two objects

The last two lines are the quiet one. A Set, a Map key or === compares object identity, so two equal instants are two different keys. Use .equals() to compare, and key collections by toString().

Every boundary changes the type

BoundaryWhat arrives on the other sideBasis
JSON.stringify then JSON.parseAn ISO stringTested
BullMQ job dataAn ISO string; job data is stored with JSON.stringify and read back with JSON.parse4BullMQ docs
structuredClone, worker_threads postMessageNothing: DataCloneError is thrownTested
new Date(value)Nothing: TypeError is thrownTested
MongoDB driver (BSON)Needs checking; see belowNot verified
node-postgres parametersNeeds checking; see belowNot verified

The errors are the good outcomes. They fail at the line that caused them. The strings are worse, because the code that receives them was written expecting an object with methods.

JSON: out as a string, back as a string

Terminal
$ node -e 'const i = Temporal.Instant.from("2026-10-05T09:30:00.123456789Z");const back = JSON.parse(JSON.stringify({ at: i }));console.log(typeof back.at, back.at)'string 2026-10-05T09:30:00.123456789Z

Every Temporal type has a toJSON() that returns its string form, so serialisation never fails. That is what makes it easy to miss: a handler that calls body.at.until(...) after parsing a request gets TypeError: body.at.until is not a function, far from the code that produced the JSON.

A zoned value is a sharper case. Temporal.ZonedDateTime serialises with its time zone in brackets: 2026-10-05T10:30:00.123456789+01:00[Africa/Casablanca]. In the V8 build tested here, passing that string to new Date() gives Invalid Date. Any client that still parses your API's timestamps with Date breaks as soon as you start returning zoned values. Send an Instant, or a separate offset and zone, to clients you do not control.

BullMQ: the worker gets the string

BullMQ's documentation is explicit: job data goes through JSON.stringify on the way in and JSON.parse on the way out, so a worker sees JSON-compatible values only4. A producer that passes an Instant in job data hands the worker a string.

src/reminders.jsJavaScript
import { Queue, Worker } from "bullmq"; const connection = { host: "localhost", port: 6379 };const reminders = new Queue("reminders", { connection }); export async function scheduleReminder(userId, dueAt) {  const delay = Math.max(0, Math.round(Temporal.Now.instant().until(dueAt).total({ unit: "millisecond" })));  await reminders.add("remind", { userId, dueAt: dueAt.toString() }, { delay });} new Worker("reminders", async (job) => {  const dueAt = Temporal.Instant.from(job.data.dueAt);  const lateBy = Temporal.Now.instant().since(dueAt).total({ unit: "second" });  console.log(`reminder for ${job.data.userId}, ${lateBy.toFixed(1)}s after it was due`);}, { connection });

Converting with toString() before add makes the string explicit, so nobody downstream assumes otherwise. The worker turns it back into an Instant on its first line. The delay option still takes milliseconds, and until(...).total(...) produces them without touching Date.

Worker threads refuse it outright

structuredClone throws DataCloneError for a Temporal value, and so does postMessage to a worker thread:

Terminal
$ node -e 'structuredClone(Temporal.Now.instant())'DOMException [DataCloneError]: [object Temporal.Instant] could not be cloned.

It fails loudly and at the call site, so it is the easiest boundary to fix: send toString() and parse on the other side.

At every boundary a Temporal value becomes something else. The safe ones turn it into a string and say so. The dangerous ones might turn it into nothing.

One module for every boundary

Rather than converting by hand in each handler, put the conversions in one place and add a guard that refuses to let a Temporal value through unconverted.

src/temporal-boundary.jsJavaScript
const TEMPORAL_TAG = /^\[object Temporal\./; export function isTemporal(value) {  return value !== null && typeof value === "object" && TEMPORAL_TAG.test(Object.prototype.toString.call(value));} export function assertNoTemporal(value, path = "$") {  if (isTemporal(value)) {    const type = Object.prototype.toString.call(value).slice(8, -1);    throw new TypeError(`${path} is a ${type}; convert it before it leaves the process`);  }  if (value === null || typeof value !== "object" || value instanceof Date || ArrayBuffer.isView(value)) return;  for (const [key, inner] of Object.entries(value)) {    assertNoTemporal(inner, Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`);  }} // BSON dates and JavaScript Dates hold milliseconds; anything finer is dropped.export const instantToDate = (instant) => new Date(instant.epochMilliseconds);export const dateToInstant = (date) => Temporal.Instant.fromEpochMilliseconds(date.getTime()); export function zonedToDoc(zoned) {  return { at: instantToDate(zoned.toInstant()), timeZone: zoned.timeZoneId };} export function docToZoned(doc) {  return dateToInstant(doc.at).toZonedDateTimeISO(doc.timeZone);} export function reviveInstants(...fields) {  const wanted = new Set(fields);  return (key, value) => (wanted.has(key) && typeof value === "string" ? Temporal.Instant.from(value) : value);}

isTemporal relies on the Symbol.toStringTag every Temporal type carries, so one check covers all of them. assertNoTemporal names the exact path of the first offender:

Terminal
$ node -e 'import("./src/temporal-boundary.js").then(m => m.assertNoTemporal({ user: { createdAt: Temporal.Now.instant() } }))'TypeError: $.user.createdAt is a Temporal.Instant; convert it before it leaves the process

reviveInstants is a JSON.parse reviver for the fields you know hold instants: JSON.parse(body, reviveInstants("createdAt", "dueAt")).

MongoDB: the boundary that may not complain

The MongoDB driver serialises a value with its toBSON() method when it has one5. Temporal types do not. They also have no own enumerable properties — Object.keys(Temporal.Now.instant()) returns []. A serialiser that walks an object's keys therefore has nothing to write.

Whatever that check prints, convert explicitly and guard the write, so the result does not depend on driver internals:

src/orders.jsJavaScript
import { MongoClient } from "mongodb";import { assertNoTemporal, dateToInstant, instantToDate } from "./temporal-boundary.js"; const client = new MongoClient(process.env.MONGODB_URI ?? "mongodb://localhost:27017");await client.connect();const orders = client.db("shop").collection("orders"); export async function createOrder(order) {  const doc = { ...order, placedAt: instantToDate(order.placedAt), deliveryDate: order.deliveryDate.toString() };  assertNoTemporal(doc);  await orders.insertOne(doc);} export async function findOrder(id) {  const doc = await orders.findOne({ _id: id });  if (!doc) return null;  return { ...doc, placedAt: dateToInstant(doc.placedAt), deliveryDate: Temporal.PlainDate.from(doc.deliveryDate) };}

Three storage choices are made here, and each is deliberate:

  • Instants become BSON dates. Range queries, indexes and TTL indexes keep working. BSON dates hold milliseconds, so microseconds and nanoseconds are dropped. If you need them, store epochNanoseconds as well.
  • Calendar dates become YYYY-MM-DD strings. A PlainDate has no time and no zone, so storing it as a Date would invent midnight in some zone and drift when read elsewhere. Fixed-width ISO date strings sort correctly as strings, so range queries still work.
  • Zoned values become two fields. A BSON date has no zone. zonedToDoc stores the instant and the IANA zone name side by side, and docToZoned rebuilds the value.

PostgreSQL: send strings, parse strings

Postgres's timestamptz stores microseconds, so round an instant before sending it. Pass it as a string rather than relying on how the driver treats an unfamiliar object:

src/logins.jsJavaScript
import pg from "pg"; // timestamptz (OID 1184): parse Postgres's text output straight into an Instant.pg.types.setTypeParser(1184, (text) => Temporal.Instant.from(text)); const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); export async function recordLogin(userId, at) {  await pool.query("INSERT INTO logins (user_id, at) VALUES ($1, $2)", [    userId,    at.round({ smallestUnit: "microsecond" }).toString(),  ]);} export async function lastLogin(userId) {  const { rows } = await pool.query("SELECT at FROM logins WHERE user_id = $1 ORDER BY at DESC LIMIT 1", [userId]);  return rows[0]?.at ?? null;}

Reading back with a type parser avoids a detour through Date, which would cut microseconds down to milliseconds. Temporal.Instant.from accepts Postgres's default text output — 2026-10-05 09:30:00.123456+00, with a space and a short offset — as tested here. setTypeParser is global to the pg module, so every timestamptz column in the process now comes back as an Instant. Make that change in one commit, and check the call sites that expected a Date.

Before you move a service to Node.js 26

  • No comparisons, sorts or new Date() calls take Temporal values directly; they use compare, equals or epochMilliseconds.
  • No Set, Map or === relies on two Temporal objects being the same object.
  • Every API response, queued job and worker message converts Temporal values to strings explicitly, and the receiving side parses them on arrival.
  • Zoned values sent to clients you do not control are sent as instants or with a separate offset and zone.
  • Database writes go through assertNoTemporal, and you have run the BSON check against your driver.
  • Code ported from an experimental Temporal build uses timeZoneId, not timeZone.

If you take one thing from this: inside the process, Temporal; at every edge, a string or a Date you converted on purpose, checked by a guard that throws if you forgot.

Notes

  1. Node.js 26.0.0 release notes (5 May 2026): https://nodejs.org/en/blog/release/v26.0.0/ ↩
  2. InfoQ, "Node.js 26: Temporal API Enabled by Default, V8 14.6, and a Round of Deprecations": https://www.infoq.com/news/2026/07/nodejs-26-temporal/ ↩
  3. MDN, Temporal.Instant.prototype.valueOf(): https://developer.mozilla.org/docs/web/javascript/reference/global_objects/temporal/instant/valueof ↩
  4. BullMQ API reference, Job.data: https://docs.bullmq.io/api/classes/v5.Job.html ↩
  5. mongodb/js-bson README, custom serialisation with toBSON(): https://github.com/mongodb/js-bson ↩
Found this useful? Share itXLinkedIn