Storage, domain, edge
The storage, domain, edge architecture for time, storing an instant as a Postgres timestamptz and reading it back as a Temporal.Instant through a custom Drizzle column.
A user clicks “Create invoice” at 11:47 PM Pacific on March 8, 2026, the night the United States springs forward and the wall clock skips from 1:59 AM straight to 3:00 AM. Your Vercel function runs in UTC, your Neon Postgres runs in UTC, the row gets written, nothing breaks. Six months later a sales lead asks for a customer-facing audit log showing each event “in the user’s timezone.” Whether that takes an afternoon or a migration was decided the day you picked the type for that one column.
Three storage shapes were on the table: the local wall-clock string the user saw ('2026-03-08 23:47'), a plain timestamp you agree informally means UTC, or a timestamptz that carries genuine UTC semantics.
Only the third keeps a time’s rendering independent from its storage, and that independence is what makes the audit log cheap.
With the local string, “show this in Tokyo time” means re-parsing text whose original zone you have to guess; with timestamptz, it is a formatting call at the edge that never touches the column or the query.
This course calls that separation storage, domain, edge, and you already have every piece.
The Temporal chapter gave you the five Temporal types, the lib/temporal.ts seam, and ISO 8601 as the wire format; the Drizzle chapter gave you timestamptz versus plain timestamp.
This lesson names the architecture that ties them together and adds one piece of code: a Drizzle column that reads a timestamptz straight back as a Temporal.Instant, so you never convert by hand.
The three-layer split
Section titled “The three-layer split”An instant, a fixed point in real time like “when this invoice was created,” lives in three shapes depending on where in your system it sits.
In storage, Postgres, the instant is a timestamptz: eight bytes of UTC, no human in the picture.
In the domain, your application’s memory while a request runs, it is a Temporal.Instant, the type your business logic compares, sorts, and does arithmetic on.
At the edge, where a value is rendered for a person, it becomes a local string like "Mar 8, 2026, 11:47 PM PST", and only here does the user’s timezone enter.
One carrier runs between the layers: the ISO 8601 string, like 2026-03-09T07:47:00.038Z, where the trailing Z means UTC.
Every time an instant crosses a boundary, out of Postgres into your code or out of your code onto an API response, it travels as that string.
The Temporal types live inside the layers; the string moves between them.
timestamptz 8 bytes · µs since 2000 · UTC Temporal.Instant "Mar 8, 2026, 11:47 PM PST" user timezone enters here The simpler alternatives couple two things that change for different reasons. Storage changes when your data model changes. Rendering changes constantly: a new locale, a user who travels, a report grouped by the recipient’s day instead of yours. Keep “render in the user’s timezone” a pure edge concern and you add it without migrating a single row; let it leak into the column the day you store a local string, and every rendering change becomes a data change.
How timestamptz stores and renders time
Section titled “How timestamptz stores and renders time”The name misleads.
timestamptz reads as “timestamp with time zone,” which suggests the column stores a zone alongside the time.
It does not.
A timestamptz is eight bytes: a count of microseconds since midnight UTC on January 1, 2000.
The instant is absolute, so the same eight bytes mean the same moment in São Paulo, Berlin, and Honolulu.
The “time zone” in the name refers to a conversion Postgres runs on the way in and out, driven by a per-connection setting, the session TimeZone .
On INSERT, Postgres interprets your input string as written in that zone, converts to UTC, and stores the UTC bytes.
On SELECT, the same setting decides how those bytes render back into text.
The bytes are fixed; the text depends on the session that reads them. Picture one row, written once, read by two connections.
SET TIME ZONE 'UTC';SELECT created_at::text FROM invoices WHERE id = '...';-- → 2026-03-09 07:47:00.038+00
SET TIME ZONE 'America/Los_Angeles';SELECT created_at::text FROM invoices WHERE id = '...';-- → 2026-03-08 23:47:00.038-08Same eight bytes, but the printed strings disagree by eight hours. An application that reasoned about times by string-matching this text would quietly break under a non-UTC session.
Two practices prevent that, and you already follow both: pin the session TimeZone to UTC, and write every input as an ISO 8601 string with a Z.
Neon and Vercel default their connections to UTC, so you get the first for free; an explicit SET TIME ZONE 'UTC' in the database client guards against a managed default drifting.
The Z removes the rest: a string that already says “this is UTC” never depends on the session’s interpretation.
This is why rendering belongs at the edge. Postgres can localize a timestamp by changing the session zone, but it does so for the whole connection, not per user, and your serverless functions share connections. Only the edge knows which user is reading and what their zone is.
The alternative is plain timestamp, without the time zone.
It runs no conversion: it stores the literal wall-clock string you hand it.
Have two services on machines with different local clocks both write “now,” and they store different values for the same real moment, with nothing to flag it.
Postgres’s own Don’t Do This list names this directly.
A plain timestamp is legitimate in exactly one case: a data pipeline whose source disclaims any zone, like a CSV exported from a “local clock, no zone recorded” system.
Everything else is timestamptz.
The Drizzle column and its read mode
Section titled “The Drizzle column and its read mode”This is the boundary between storage and domain.
In the Drizzle chapter you wrote the column with timestamp({ withTimezone: true }), which is correct for storage: it emits a timestamptz.
What this lesson settles is the type your TypeScript gets back when you read that column, where the obvious answer is wrong.
Drizzle’s timestamp builder takes a mode that decides the JavaScript type of a read.
It has two built-in choices, and neither is the answer.
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date',}).notNull().defaultNow(),Hands back a Date. Drizzle’s default drags into your domain every problem the Temporal pivot retired: zero-indexed months, silent mutation, getMonth() reading the runtime’s local zone. The wrong type for a codebase that banned Date from the domain.
createdAt: timestamp('created_at', { withTimezone: true, mode: 'string',}).notNull().defaultNow(),Hands back Postgres’s raw text, '2026-03-09 07:47:00.038+00'. The type is right, but the shape is not: a space where canonical ISO 8601 uses T, and an hours-only +00 offset instead of the ±HH:mm shape (+00:00) a clean interchange string carries. A strict ISO 8601 parser may reject that offset, so you must normalize the text before handing it to Temporal.
So 'date' gives the wrong type and 'string' gives the wrong shape; neither raw mode lets a query just return a Temporal.Instant.
The fix is to convert inside the column instead of at the call site.
Drizzle’s customType lets you define a column with your own conversions for the two directions a value travels: toDriver (your code to the database) and fromDriver (the database to your code).
Put the Temporal conversion there and it runs automatically on every read and write through the schema, written once in lib/temporal.ts, the seam file that already owns your Temporal import.
// lib/temporal.ts — built on the existing seam fileimport { customType } from 'drizzle-orm/pg-core';import { Temporal } from '@/lib/temporal';
export const instantColumn = customType<{ data: Temporal.Instant; driverData: string;}>({ dataType: () => 'timestamp (3) with time zone', toDriver: (value) => value.toString(), fromDriver: (value) => Temporal.Instant.from(value.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00')),});The generic is the contract. data is what your application code sees, a Temporal.Instant; driverData is the string the Postgres driver moves over the wire. The schema reads and writes Temporal.Instant; the driver only ever sees a string.
// lib/temporal.ts — built on the existing seam fileimport { customType } from 'drizzle-orm/pg-core';import { Temporal } from '@/lib/temporal';
export const instantColumn = customType<{ data: Temporal.Instant; driverData: string;}>({ dataType: () => 'timestamp (3) with time zone', toDriver: (value) => value.toString(), fromDriver: (value) => Temporal.Instant.from(value.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00')),});The SQL the migration emits: a timestamptz at precision: 3. Postgres stores microseconds, but Temporal.Instant and the wire carry milliseconds, so pinning the column to millisecond precision keeps a write-then-read round-trip from drifting on a dropped microsecond tail. (Compare instants with .epochMilliseconds or .equals(), never by string-equality across a round-trip.)
// lib/temporal.ts — built on the existing seam fileimport { customType } from 'drizzle-orm/pg-core';import { Temporal } from '@/lib/temporal';
export const instantColumn = customType<{ data: Temporal.Instant; driverData: string;}>({ dataType: () => 'timestamp (3) with time zone', toDriver: (value) => value.toString(), fromDriver: (value) => Temporal.Instant.from(value.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00')),});The read seam, where the two .replace() calls do different jobs. The space-to-T swap is cosmetic: a space is a valid date-time separator and T is just the canonical one. The repair that matters is the offset: Postgres emits an hours-only +00 or -08, but a clean ISO 8601 carrier uses ±HH:mm, so /([+-]\d{2})$/ matches the trailing two-digit offset and pads it with :00. With both applied, the string is unambiguous ISO 8601. Because from throws a RangeError on anything malformed, this line also doubles as the parse gate for every timestamp coming out of the database.
// lib/temporal.ts — built on the existing seam fileimport { customType } from 'drizzle-orm/pg-core';import { Temporal } from '@/lib/temporal';
export const instantColumn = customType<{ data: Temporal.Instant; driverData: string;}>({ dataType: () => 'timestamp (3) with time zone', toDriver: (value) => value.toString(), fromDriver: (value) => Temporal.Instant.from(value.replace(' ', 'T').replace(/([+-]\d{2})$/, '$1:00')),});The write seam, one line because the work was already done. Instant.prototype.toString() produces the canonical ISO 8601 string with a Z, exactly the shape Postgres ingests cleanly. The problem only appears on the way out of Postgres, so no repair is needed here.
Why the column rather than a free-floating { fromDb, toDb } pair you call at each query?
A conversion the call site has to remember is one it will eventually forget.
In the column, forgetting becomes impossible: db.select() already hands back a Temporal.Instant, and an insert already accepts one, so the conversion is structural rather than a habit.
And .defaultNow() still chains on, because Postgres computes that default server-side (CURRENT_TIMESTAMP); the column type only governs values that cross the TypeScript boundary.
The trace below follows one createdAt through the column on a read and then a write.
'2026-03-09·07:47:00.038+00' The hours-only +00 offset is the load-bearing problem — a
clean carrier wants +00:00. (The space is fine on its own;
a T is just the canonical separator.) fromDriver · inside the column '…T07:47:00.038+00:00' → Temporal.Instant .replace(/([+-]\d{2})$/, '$1:00') pads the
hours-only offset to the +00:00 that
Temporal.Instant.from round-trips cleanly (the space
becomes a T in the same pass). invoice.createdAt.epochMilliseconds It's a Temporal.Instant already — the column did the
work. No .fromDb(…) in sight. Temporal.Now.instant() An insert accepts a Temporal.Instant directly — the
same column converts on the way in. toDriver · one line, no repair '2026-03-09T07:47:00.038Z' Instant.toString() already produces the canonical
Z string Postgres ingests cleanly — it lands as
timestamptz. Concentrating this in one file pays off the polyfill promise.
Moving from the polyfill to native Node 26 Temporal was supposed to be a single-line change, and here it comes due: swap the Temporal import at the top of lib/temporal.ts, and every column built on instantColumn and every line that reads one keeps working.
This holds because ISO 8601 strings with Z are the universal carrier, and everything except Postgres’s read text already speaks them: Postgres ingests them, Temporal.Instant.from() parses them, toString() and toJSON() produce them, and every API this course consumes, including Stripe, Trigger.dev, and Resend, emits and accepts them.
Postgres’s hours-only-offset read text is the single exception, and the column isolates it.
What application code sees: Temporal, never Date
Section titled “What application code sees: Temporal, never Date”Because the column converts on read, a row’s createdAt is a Temporal.Instant the moment you read it.
You reach for the Temporal surface, .epochMilliseconds, .toString(), .since(other), never for Date methods like .getMonth() or .toISOString(): the value isn’t a Date, so those methods don’t exist and your editor flags them.
Three call sites show the shape across a read, a write, and the wire.
const invoices = await listInvoices(orgId);
const sorted = invoices.toSorted((a, b) => Temporal.Instant.compare(a.createdAt, b.createdAt),);The read direction is automatic. No fromDb(...) call clutters the query: instantColumn already converted on the way out, so each createdAt is a Temporal.Instant you hand straight to Temporal.Instant.compare.
export const recordPayment = async (input: PaymentInput) => { const { orgId } = await requireOrgUser(); await db.insert(payments).values({ invoiceId: input.invoiceId, // server clock is the authority — never accept this from the client receivedAt: Temporal.Now.instant(), });};The write direction takes a Temporal.Instant too. The action re-derives the instant on the server rather than accepting one from the client; the next section is about why.
export const GET = async () => { const invoice = await getInvoice(id); return Response.json({ id: invoice.id, createdAt: invoice.createdAt.toString(), });};The wire direction is the one manual encode. A Temporal.Instant is a class instance, so it can’t cross a JSON response or the React Server Component wire as-is. Call .toString() (or let JSON.stringify invoke toJSON()) to emit the ISO string.
One asymmetry is where students trip.
The database direction is automatic both ways; the wire direction, in a hand-written response, is explicit, so you call .toString() to encode the instant.
Pass a raw Temporal.Instant as a prop from a Server Component to a Client Component, or return one bare from a route handler, and serialization fails.
The fix is always the same: encode to the ISO string at the boundary, parse back on the other side.
Temporal in memory, ISO 8601 on the wire.
You have a value created typed Temporal.Instant, read off a query row. Three of these lines compile; one calls a method that doesn’t exist on an Instant. Which one fails?
created.epochMillisecondscreated.since(other)created.getMonth()created.toString()getMonth() is a Date method, and the column hands you a Temporal.Instant. An instant doesn’t even have a month without a timezone attached; “which month was this” you answer by converting to a ZonedDateTime in the user’s zone first, a later lesson. The other three are core Instant members: .epochMilliseconds for the numeric value, .since(other) for a duration, .toString() for the ISO string.
The server clock is the authority
Section titled “The server clock is the authority”The action above stamped Temporal.Now.instant() on the server rather than taking the value from the client, ruling out a whole category of bugs in one line.
Every server-meaningful instant is stamped by the server, never sent by the client: createdAt, updatedAt, processedAt, expiresAt, and the rest of the timestamps your business logic and idempotency reasoning depend on.
Pick the source by where the value is computed.
When Postgres can produce it, reach for defaultNow(): the database stamps CURRENT_TIMESTAMP and you write nothing.
When you need the instant in TypeScript before the insert, to compute an expiry, say, Temporal.Now.instant() is the source.
The anti-pattern is a Server Action that accepts createdAt in its payload and writes whatever the client sent.
A client’s clock can be wrong by minutes, skewed by a stale device, or set deliberately by an attacker.
The fix is structural, not a validation patch: drop the client-supplied instant and re-stamp on the server.
Even a legitimately client-originated timestamp, such as “when the user tapped the button while offline,” gets validated at the seam, never trusted raw.
Where Date is still allowed
Section titled “Where Date is still allowed”The Temporal pivot warned against over-correcting, against purging Date from places where it belongs.
Two seams legitimately produce a Date, and the rule for both is the one from the pivot chapter: convert at the seam, never propagate inward.
The first is third-party SDKs that hand you a Date, and Stripe is the case you’ll actually hit.
instantFromDate in lib/temporal.ts converts the Date to a Temporal.Instant the moment it crosses into your code, and it never travels further.
Stripe carries one sharper pitfall at the storage boundary, because getting it wrong writes garbage into your timestamptz column.
// Stripe webhook fields like `created` are Unix SECONDS, not milliseconds.const occurredAt = instantFromUnixSeconds(event.created);
const wrong = Temporal.Instant.fromEpochMilliseconds(event.created);Raw webhook fields like created arrive as Unix seconds, a ten-digit integer.
Hand event.created straight to Temporal.Instant.fromEpochMilliseconds and you’ve told Temporal a seconds count is a milliseconds count: the result lands in January 1970, off by a factor of a thousand.
instantFromUnixSeconds multiplies by 1000 at the seam so this can’t happen; when the SDK wraps the value in a Date instead, instantFromDate is the converter.
The second seam is stopwatch-style duration measurement, where only the gap between two readings matters; there performance.now() is the right tool, sub-millisecond and monotonic, so it doesn’t jump when the system clock corrects.
So the rule lands where it did before, now anchored against real storage: Date at the seam, Temporal in the domain.
A Date anywhere other than an SDK adapter or a stopwatch is a warning sign that a conversion got skipped, leaving the value one .getMonth() away from a timezone bug.
The two Drizzle-to-Temporal pairs
Section titled “The two Drizzle-to-Temporal pairs”This chapter installs two Drizzle-to-Temporal pairs, and you’ve built the first.
This lesson did the instant pair: timestamptz in storage, Temporal.Instant in the domain, joined by the instantColumn you wrote.
The next lesson adds the calendar-day pair: a date column joined to Temporal.PlainDate, for values like a due date or a birthday, where the answer is “May 15” regardless of where anyone stands.
Both pairs live in lib/temporal.ts.
One test sorts any column into the right pair: is the answer “this exact second”? When a row was created, when a payment arrived, when a token expires, that’s the instant pair you just built. If the answer is “this calendar day, everywhere,” it’s the next lesson’s pair.
Two layers of the strip are now real, storage and domain, with ISO 8601 carrying values between them. The third, the edge, where the user’s profile timezone finally enters, gets its own lesson next.
External resources
Section titled “External resources”The customType reference: dataType, toDriver, and fromDriver — the three hooks the instantColumn is built on.
The mode and precision options for the built-in timestamp builder, and exactly what each read mode returns.
The authority on what timestamptz stores and how the session TimeZone drives input interpretation and output rendering.
The reference for the domain type this lesson installs: from(), compare(), epochMilliseconds, since(), toString().
Jon Skeet on where UTC-everywhere stops being enough — the future and recurring events that motivate the chapter's later lessons.