Your own client ↔ server

You control both ends — agree one explicit wire contract and serialize symmetrically.

When you own both the client and the server, you get to define the wire format — and that is a gift worth using carefully. Pick one explicit representation, apply it consistently everywhere, and document it. The safest choice for most applications is RFC 3339 in UTC: human-readable, unambiguous, supported natively or near-natively in every modern language, and sortable as a string. A value looks like 2026-06-12T08:30:00Z — the date, a T, a 24-hour time, and Z for UTC.

None of this makes ISO 8601 the enemy — it is broader than RFC 3339, and that breadth is often exactly what you want. When you need to put a duration (P1Y2M10DT2H30M) or an interval on the wire, ISO 8601 expresses it and RFC 3339 cannot. The real rule is that the producer documents the exact format and the consumer relies on it; the breadth only bites when it is left unstated, because “we use ISO 8601” still lets a counterparty send a week date or an offset-less local time your parser never anticipated. Since you own both ends here, you have all the freedom you need — agree on a specific subset, write it down, and pick the representation that fits the value. RFC 3339 in UTC is simply the safest default for a plain instant (what ISO 8601 actually allows).

The classic failure mode is trusting the language’s default serialization. JavaScript’s Date.prototype.toString() emits a locale-specific, implementation-defined string. Python’s datetime.__str__ omits the offset if the object is naive. If client and server independently call toString() or str(), they may agree today and diverge when you upgrade a library or deploy to a server in a different locale.

Define the contract once, enforce it at both ends, and treat any deviation as a bug — not a “close enough”.

Serialize and deserialize symmetrically

Every path that sends a timestamp (REST body, WebSocket message, GraphQL field, cookie, local-storage value) should use the same serializer. Every path that receives one should use the same deserializer. If you find yourself parsing a date in three different ways in three different files, that is technical debt accumulating against correctness.

Pitfall: Relying on JSON.stringify to serialize a JavaScript Date object. It calls toISOString(), which gives UTC with milliseconds — good — but JSON.parse deserializes it back as a plain string, not a Date. The round-trip is not symmetric, so a date value read back from JSON is silently a string until something breaks.

Go deeper: versioning, clocks, and typed schemas

Document and version the contract. Put the wire format in an API doc, a schema (OpenAPI, Protobuf, GraphQL type), or at minimum a code comment that says “ISO 8601 / RFC 3339 UTC, millisecond precision”. Future developers — and future services that consume your API — will thank you. See RFC 3339 for the format spec.

Schema enforcement. If you use a typed schema layer (Zod, Pydantic, Protobuf), express timestamps as a dedicated type with explicit parsing. Don’t accept string and handle parsing ad hoc — that is how inconsistencies accumulate.

Clock skew. Even when client and server agree on format, their clocks may disagree by seconds or minutes. For ordering events, prefer server-assigned timestamps over client-supplied ones. For durations measured across the round-trip, account for skew or use a monotonic clock for the client-local measurement. See Store vs display for the principle of keeping instants in UTC and converting only at display time.

Relative vs absolute. Avoid sending relative durations (“5 minutes ago”) over the wire as your source of truth — they depend on when they are evaluated. Send the absolute instant and let the receiver render the relative label.


← Back to all topics