Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Temporal API in Node.js APIs: Storing, Serializing, and Returning Dates Correctly (2026)
javascript12 min read

Temporal API in Node.js APIs: Storing, Serializing, and Returning Dates Correctly (2026)

How to store, serialize, and return JavaScript Temporal API types correctly in a Node.js REST API with PostgreSQL. Real Express code and examples, 2026.

Zeeshan Tofiq
Zeeshan Tofiq
July 2, 2026
On this page

On this page

  • The Problem with Date in APIs
  • The Six Temporal Types, Briefly
  • How to Store Temporal Types in PostgreSQL
  • Serializing Temporal Objects for JSON APIs
  • A Real Express Route Example
  • Migration Strategy for Existing APIs
  • Quick Reference Table
  • Frequently Asked Questions

Temporal reached TC39 Stage 4 on March 11, 2026. After nine years of development, it is now officially part of the ES2026 specification. It is native in Chrome 144+, Firefox 139+, and ships in Node.js behind a flag today, with unflagged support expected later this year.

Most of what has been written about Temporal so far covers two things: what the API looks like, and how to migrate frontend code off Moment.js. Almost nobody has written about the questions that actually matter once you are building a real backend: what do you store in PostgreSQL, how do you serialize these objects in a JSON response, and which of the six Temporal types do you actually need.

This post answers those three questions with real Express and PostgreSQL code. If you want a broader look at how modern JavaScript runtimes handle backend data, the Bun SQL and Redis clients guide covers backend data handling patterns in modern JavaScript runtimes, and the durable workflows in PostgreSQL post covers a different (unrelated) Temporal, the workflow engine, if you landed here by mistake.

The Problem with Date in APIs

The JavaScript Date object causes specific, recurring bugs in backend code, not just frontend display glitches. Three failure modes show up again and again in API request handling.

<strong>Mutation bugs.</strong> Date objects are mutable. If you pass a Date into a function that calls .setDate() on it, you have silently mutated the caller's object too. In a request-handling pipeline where the same date passes through several middleware functions, this causes bugs that are genuinely hard to trace.

javascript — mutation-bug.js
function addDays(date, days) {
  date.setDate(date.getDate() + days); // mutates the original!
  return date;
}

const original = new Date('2026-06-15');
const later = addDays(original, 7);
console.log(original); // also changed -- this is the bug

<strong>Timezone ambiguity.</strong> new Date('2026-06-15') parses as UTC midnight. For any user west of UTC, that is the previous day in their local time. This is a constant source of off-by-one-day bugs in scheduling features.

<strong>Silent DST failures.</strong> Adding 24 hours in milliseconds to a Date is not the same as adding one day when a daylight saving transition happens to fall in between. The Date object has no concept of this distinction.

These are not display formatting problems. They are correctness problems in business logic, exactly the kind of thing that is expensive to debug in production after the fact. Temporal fixes all three: its types are immutable, every operation is explicit about timezone, and calendar arithmetic understands DST.

The Six Temporal Types, Briefly

Temporal splits what Date tried to cram into one object into six distinct types. For typical REST API work, you mainly need two of them.

  • <strong>Temporal.Instant</strong> — an exact point in time, UTC, no timezone or calendar attached. This is what a created_at timestamp usually should be.
  • <strong>Temporal.ZonedDateTime</strong> — an Instant paired with a named IANA timezone (e.g. "America/New_York"). Use this when wall-clock time and DST behavior matter, like a recurring meeting that should always happen at 9 AM local time regardless of DST shifts.
  • <strong>Temporal.PlainDate</strong> — a calendar date with no time and no timezone. Use this for birthdays, deadlines, and holidays, things that are the same date everywhere in the world.
  • <strong>Temporal.PlainDateTime</strong> — a date and time with no timezone attached. Useful for a local appointment time before you know which timezone applies.
  • <strong>Temporal.PlainTime</strong> — a time of day with no date or timezone. Useful for recurring daily events like "store opens at 09:00".
  • <strong>Temporal.Duration</strong> — an amount of time (e.g. "3 days, 4 hours"), not a point in time at all.

💡 Not sure which type your field needs?

For most backend API work, Instant and ZonedDateTime cover the large majority of fields. If you are staring at a new scheduledAt or birthDate column and are not sure which type fits, the Temporal Type Picker answers it in two questions and gives you the constructor code.

The rest of this post focuses on Instant, ZonedDateTime, and PlainDate, since those three map directly onto the most common database column types.

How to Store Temporal Types in PostgreSQL

The mapping from Temporal type to PostgreSQL column type is mostly straightforward, with one important gotcha around ZonedDateTime.

<strong>Instant maps to TIMESTAMPTZ.</strong> This is the right column type for nearly every "when did this happen" field: created_at, updated_at, completed_at.

sql — orders.sql
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    total       NUMERIC(10,2) NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
javascript — create-order.js
const { Temporal } = require('temporal-polyfill'); // or native if available

async function createOrder(total) {
  const now = Temporal.Now.instant();
  await pool.query(
    'INSERT INTO orders (total, created_at) VALUES ($1, $2)',
    [total, now.toString()] // TIMESTAMPTZ accepts the ISO string directly
  );
}

<strong>PlainDate maps to DATE.</strong> Use this for fields where the timezone is irrelevant by definition: a birthday, a contract expiration date, a holiday.

sql — employees.sql
CREATE TABLE employees (
    id          SERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    birth_date  DATE NOT NULL
);
javascript — create-employee.js
const birthDate = Temporal.PlainDate.from('1990-06-15');
await pool.query(
  'INSERT INTO employees (name, birth_date) VALUES ($1, $2)',
  [name, birthDate.toString()] // "1990-06-15"
);

⚠ The ZonedDateTime gotcha

Do not try to store the timezone name inside the same TIMESTAMPTZ column. PostgreSQL's TIMESTAMPTZ always normalizes to UTC internally and discards the original timezone offset on storage. If you need to reconstruct "this meeting happens at 9 AM New York time" later (which matters across DST changes), store the Instant and the timezone name in two separate columns.

sql — recurring_meetings.sql
CREATE TABLE recurring_meetings (
    id              SERIAL PRIMARY KEY,
    title           TEXT NOT NULL,
    next_occurrence TIMESTAMPTZ NOT NULL,
    timezone_name   TEXT NOT NULL  -- e.g. "America/New_York"
);
javascript — store-meeting.js
const meeting = Temporal.ZonedDateTime.from({
  year: 2026, month: 6, day: 15, hour: 9, minute: 0,
  timeZone: 'America/New_York',
});

await pool.query(
  'INSERT INTO recurring_meetings (title, next_occurrence, timezone_name) VALUES ($1, $2, $3)',
  ['Weekly Standup', meeting.toInstant().toString(), meeting.timeZoneId]
);

// Reconstructing later, correctly accounting for any DST shift since storage:
const row = await pool.query('SELECT * FROM recurring_meetings WHERE id = $1', [id]);
const reconstructed = Temporal.Instant
  .from(row.rows[0].next_occurrence)
  .toZonedDateTimeISO(row.rows[0].timezone_name);

This pattern (store the Instant plus the zone name separately, reconstruct the ZonedDateTime on read) is the safest approach for anything involving recurring events or wall-clock-time guarantees across DST boundaries.

Serializing Temporal Objects for JSON APIs

Temporal objects do not serialize automatically with JSON.stringify the way you might expect. You need to convert explicitly, in both directions.

javascript — serialize-gotcha.js
// Temporal objects need an explicit toString() to become JSON-safe
const instant = Temporal.Now.instant();
JSON.stringify({ createdAt: instant }); // {"createdAt":{}} -- NOT what you want!
JSON.stringify({ createdAt: instant.toString() }); // {"createdAt":"2026-06-15T14:30:00Z"} -- correct

The canonical string format for all Temporal types follows RFC 9557, an extension of ISO 8601. For an Instant, this looks like a standard ISO UTC string. For a ZonedDateTime, the timezone name is included in brackets:

javascript — zoned-string.js
zonedDateTime.toString();
// "2026-06-15T09:00:00-04:00[America/New_York]"

Most JSON consumers (frontend clients, other services) expect plain ISO strings, not the bracketed timezone suffix. A small serialization helper keeps API responses consistent.

javascript — serialization.js
function serializeTemporal(value) {
  if (value instanceof Temporal.Instant) {
    return value.toString(); // plain UTC ISO string
  }
  if (value instanceof Temporal.ZonedDateTime) {
    return value.toInstant().toString(); // drop to UTC instant for the wire
  }
  if (value instanceof Temporal.PlainDate) {
    return value.toString(); // "2026-06-15"
  }
  return value;
}

// Express response helper
app.use((req, res, next) => {
  const originalJson = res.json.bind(res);
  res.json = (body) => originalJson(JSON.parse(JSON.stringify(body, (key, val) =>
    val instanceof Temporal.Instant ||
    val instanceof Temporal.ZonedDateTime ||
    val instanceof Temporal.PlainDate
      ? serializeTemporal(val)
      : val
  )));
  next();
});

ℹ The general rule

Return UTC Instants as plain ISO strings in JSON responses. If the client needs to display the time in a specific timezone, let it do that conversion using Intl.DateTimeFormat with the user's own IANA timezone, rather than baking a timezone-specific string into the API response.

Serialization is only half the boundary. The other half is parsing the strings that arrive in request bodies back into Temporal types, and this is where input validation belongs. Temporal's from() methods are strict by design: they throw a RangeError on a malformed or out-of-range value instead of silently coercing it the way new Date('not a date') quietly produces an Invalid Date. That strictness is a feature. It turns bad input into a clear 400 response at the edge of your handler rather than a corrupt row three layers down.

Wrap the parse in a try/catch and treat any throw as a client error. Do not spread untrusted strings straight into a query. Parse first, let Temporal reject the garbage, and only then touch the database.

javascript — parse-input.js
function parseInstant(value) {
  try {
    return Temporal.Instant.from(value); // throws on bad input
  } catch {
    return null; // caller turns null into a 400
  }
}

const createdAt = parseInstant(req.body.createdAt);
if (!createdAt) {
  return res.status(400).json({ error: 'createdAt must be an ISO 8601 instant' });
}

This parse-at-the-boundary pattern gives you one consistent place to validate every incoming date field, and it keeps the rest of your handler working with real Temporal objects instead of raw strings. Combined with the serialization helper above, your business logic never sees a string date in either direction, only typed, immutable Temporal values.

A Real Express Route Example

Here is a complete route handling recurring meeting creation, covering input parsing, storage, and response, end to end.

javascript — routes/meetings.js
const express = require('express');
const { Temporal } = require('temporal-polyfill');
const router = express.Router();

router.post('/meetings', async (req, res) => {
  const { title, localTime, timeZone, startDate } = req.body;
  // Example request body:
  // { "title": "Weekly Standup", "localTime": "09:00",
  //   "timeZone": "America/New_York", "startDate": "2026-06-15" }

  try {
    // Build a ZonedDateTime from the wall-clock components the client sent
    const plainDate = Temporal.PlainDate.from(startDate);
    const plainTime = Temporal.PlainTime.from(localTime);
    const zoned = plainDate.toZonedDateTime({ timeZone, plainTime });

    // Store the UTC instant and the timezone name separately
    const result = await pool.query(
      `INSERT INTO recurring_meetings (title, next_occurrence, timezone_name)
       VALUES ($1, $2, $3) RETURNING id`,
      [title, zoned.toInstant().toString(), timeZone]
    );

    res.status(201).json({
      id: result.rows[0].id,
      title,
      nextOccurrence: zoned.toInstant().toString(), // UTC for the wire
      localTime: zoned.toPlainTime().toString(),    // "09:00:00" for display
      timeZone,
    });
  } catch (err) {
    res.status(400).json({ error: 'Invalid date, time, or timezone provided' });
  }
});

router.get('/meetings/:id', async (req, res) => {
  const result = await pool.query(
    'SELECT * FROM recurring_meetings WHERE id = $1',
    [req.params.id]
  );

  if (result.rows.length === 0) {
    return res.status(404).json({ error: 'Meeting not found' });
  }

  const row = result.rows[0];

  // Reconstruct the ZonedDateTime, correctly accounting for any DST
  // shift that may have occurred between storage and now
  const zoned = Temporal.Instant
    .from(row.next_occurrence)
    .toZonedDateTimeISO(row.timezone_name);

  res.json({
    id: row.id,
    title: row.title,
    nextOccurrence: zoned.toInstant().toString(),
    localTime: zoned.toPlainTime().toString(),
    timeZone: row.timezone_name,
  });
});

module.exports = router;

The recurring-meeting case specifically benefits from storing the timezone name separately. If "America/New_York" shifts its DST offset between when this meeting was created and when it is read back, the reconstructed ZonedDateTime automatically reflects the correct wall-clock time, 9 AM local, not 9 AM frozen at whatever UTC offset happened to be in effect at creation time.

If you are wiring up REST endpoints like this for the first time, the Node.js interview questions guide covers the runtime and async fundamentals that these handlers depend on.

Migration Strategy for Existing APIs

You do not need a big rewrite. The practical path most teams are taking is incremental.

Use Temporal for all new endpoints and new database columns going forward. Do not touch working Date-based code just to convert it. Migrate Date-based code file by file as you touch it for other reasons, not as a dedicated migration sprint. The two approaches coexist fine in the same codebase; nothing about Temporal requires an all-or-nothing cutover.

For Node.js versions without native unflagged support, install the polyfill. The API is identical to the native implementation, and the polyfill steps aside once your runtime ships native support, so there is no follow-up migration needed when you upgrade Node.js versions later.

bash
npm install @js-temporal/polyfill
javascript — temporal-import.js
const { Temporal } = require('@js-temporal/polyfill');
// Same API as native Temporal -- no code changes needed when you
// eventually upgrade to a Node.js version with unflagged native support

For ORMs (Prisma, TypeORM, Sequelize) that currently return native Date objects from query results, wrap the specific fields you care about at the boundary, converting to Temporal types right after the query returns, rather than waiting for first-class ORM support to land.

In TypeScript, model this boundary explicitly. Keep your database row types using string for the raw columns (that is what the driver hands back for TIMESTAMPTZ and DATE), and define a separate domain type that uses the Temporal classes. A small mapper function converts one to the other. This keeps the untyped edge of the system honest: nothing downstream can accidentally treat a raw column string as if it were already a Temporal object, because the types will not line up until the mapper has run.

Quick Reference Table

Which Temporal type, PostgreSQL column, and JSON format to use for the most common date and time fields
Field meaningTemporal typePostgreSQL columnJSON wire format
created_at, updated_atTemporal.InstantTIMESTAMPTZISO UTC string
Recurring event timeTemporal.ZonedDateTimeTIMESTAMPTZ + TEXT (zone name, separate column)UTC instant + zone name
Birthday, deadline, holidayTemporal.PlainDateDATE"YYYY-MM-DD"
Daily opening/closing timeTemporal.PlainTimeTIME"HH:MM:SS"
Estimated durationTemporal.DurationINTERVAL or INTEGER (seconds)ISO 8601 duration string

Frequently Asked Questions

Do I need a polyfill to use Temporal in Node.js today?

As of mid-2026, Temporal ships in Node.js behind a flag, with unflagged native support expected later in the year. Until your runtime enables it by default, install @js-temporal/polyfill. The API is identical to native, so no code changes are needed when native support lands, the polyfill simply steps aside.

What is the difference between Temporal.Instant and Temporal.ZonedDateTime for a database column?

An Instant is a bare point in UTC with no timezone attached. Store it in a single TIMESTAMPTZ column. That covers the vast majority of "when did this happen" fields like created_at.

A ZonedDateTime is an Instant plus a named IANA timezone. If wall-clock time must survive DST changes (a recurring 9 AM meeting), store the Instant in TIMESTAMPTZ and the zone name in a separate TEXT column, then reconstruct the ZonedDateTime on read. Never try to cram the zone name into the TIMESTAMPTZ column, PostgreSQL discards it.

Does Temporal work with Express?

Yes. Temporal is a plain JavaScript API with no framework coupling, so it works in any Express route handler. Because Temporal objects do not serialize automatically through res.json(), add a small serialization middleware that converts Instant, ZonedDateTime, and PlainDate to strings before the response is sent. There is a complete example in the serialization section above.

What about my existing Date-based ORM (Prisma, TypeORM, Sequelize)?

ORMs still return native Date objects from query results, and first-class Temporal support is not universal yet. The practical approach is to convert at the boundary: take the Date the ORM returns and wrap the specific fields you care about into the correct Temporal type immediately after the query resolves.

javascript
const row = await prisma.order.findUnique({ where: { id } });
const createdAt = Temporal.Instant.fromEpochMilliseconds(row.createdAt.getTime());
Why can't I store the timezone name in a TIMESTAMPTZ column?

Despite the name, TIMESTAMPTZ does not store a timezone. PostgreSQL converts the incoming value to UTC on write and stores only that UTC instant. On read it applies the session timezone for display. The original IANA zone name ("America/New_York") is never persisted. If you need it back, for example to keep a recurring meeting at 9 AM local across a DST change, you must store the zone name in a separate TEXT column.

When should I use PlainDate instead of PlainDateTime?

Use PlainDate when there is no time of day at all: a birthday, a contract deadline, a public holiday. These are the same calendar date everywhere in the world, so they map to a PostgreSQL DATE column.

Use PlainDateTime when you have a date and a wall-clock time but no timezone yet, for example a local appointment time captured before you know the user's zone. If you are unsure which fits your field, the Temporal Type Picker walks you through it in two questions.

How do I store a Temporal.Duration in PostgreSQL?

Two options. If you want PostgreSQL to understand the duration for interval arithmetic, store it in an INTERVAL column, Temporal's ISO 8601 duration string (e.g. "PT3H30M") is compatible. If you only need to read it back and never do database-side math, store the total as an INTEGER count of seconds, which is simpler to index and aggregate.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

javascript

Bun 1.3's Built-in SQL and Redis Clients: Do You Still Need pg, mysql2, and ioredis?

Bun 1.3 ships built-in Postgres, MySQL, SQLite, and Redis clients. Side-by-side code vs pg, mysql2, and ioredis, plus when migrating actually makes sense.

Jun 26, 2026·18 min read
database

pg_durable: Durable Workflows in PostgreSQL vs Temporal and Cron+Queue

Microsoft's pg_durable brings durable workflow execution inside PostgreSQL. Here is the syntax, what it replaces, and an honest comparison against Temporal and cron+queue patterns.

Jun 23, 2026·9 min read

On this page

  • The Problem with Date in APIs
  • The Six Temporal Types, Briefly
  • How to Store Temporal Types in PostgreSQL
  • Serializing Temporal Objects for JSON APIs
  • A Real Express Route Example
  • Migration Strategy for Existing APIs
  • Quick Reference Table
  • Frequently Asked Questions
Advertisement