Time looks simple until an application has users in several countries.
A user creates a meeting at 9:00. The backend receives a date and a time. The database stores a value. Later, another user opens the same meeting from a different country.
What should that user see?
My first instinct would have been to answer: store everything in UTC. This advice is useful, but it is incomplete. It works well for an event that represents one exact point on the timeline. It is not enough for a birthday, a shop opening time, or a meeting that must remain at 9:00 in Paris after time-zone rules change.
While exploring the subject, I found that the main difficulty is not conversion. It is deciding what a value means before choosing its Java type, JSON format, and database column.
The most useful rule I found was this:
Do not start with the format. Start with the meaning.
One field can represent different ideas
The java.time API has several types because time is not one concept. The Java documentation separates dates, local times, instants, offsets, and full time zones.
For a backend, I would begin with these questions:
| Domain question | Recommended Java type | Example |
|---|---|---|
| When did this event happen? | Instant |
2026-11-15T13:30:00Z |
| What is the calendar date? | LocalDate |
2026-11-15 |
| What is the local time of day? | LocalTime |
09:00:00 |
| What local date and time did a user enter? | LocalDateTime |
2026-11-15T14:30:00 |
| What is the UTC offset in a message? | OffsetDateTime |
2026-11-15T14:30:00+01:00 |
| What local time follows regional rules? | ZonedDateTime / ZoneId |
2026-11-15T14:30+01:00[Europe/Paris] |
These types are not interchangeable.
A birthday is a LocalDate. Converting it to midnight UTC can move it to the previous day for some users.
An audit entry is an Instant. It describes one exact moment, independently of the person reading it.
A weekly appointment at 9:00 in Paris is a local time connected to the rules of Europe/Paris. It is not simply a fixed UTC offset.
This distinction is the foundation for every other decision.
UTC is the right default for events
For events that have happened, or that represent one exact moment, Instant is usually the clearest type.
Examples include:
- an account creation time;
- a payment confirmation;
- an authentication attempt;
- a message publication time;
- the moment when a job started or finished.
An Instant is one point on the global timeline. In a Spring Boot JSON API, it can be serialized as an ISO-8601 value with a Z suffix:
{
"createdAt": "2026-11-15T13:30:00Z"
}
The client can convert this value to the user's zone for display. The backend can compare and sort instants without first knowing where each user lives.
In a Spring service, I would keep the operation explicit:
Instant createdAt = clock.instant();
This is clearer than using a server-local LocalDateTime.now(). A local date-time does not contain an offset or a zone, so it cannot identify one unique moment by itself.
UTC is therefore a good storage and exchange reference for events. However, UTC should not erase the local information that is part of the business rule.
An offset is not a time zone
+01:00 is an offset. Europe/Paris is a time-zone identifier.
The difference matters because an offset is fixed for one moment, while a regional time zone contains rules. Paris can use +01:00 in winter and +02:00 in summer. Governments can also change these rules.
The IANA Time Zone Database is updated when political decisions change UTC offsets, daylight-saving rules, or zone boundaries. Java uses region-based identifiers such as:
Europe/Paris
America/New_York
Asia/Tokyo
Africa/Douala
I would store these identifiers instead of abbreviations such as CET, EST, or CST. Abbreviations can be ambiguous, and a fixed offset cannot describe future daylight-saving changes.
This also means that locale and time zone must remain separate concepts:
fr-FRhelps format a date in French for France;Europe/Parisprovides the time-zone rules;- a French-speaking user may live in Canada, Cameroon, or Japan.
The backend should not guess a time zone from a language or country code.
Future schedules need the user's intention
Imagine that a user schedules a meeting for:
15 November 2026 at 14:30 in Europe/Paris
For this one occurrence, the backend can resolve the local value to an instant:
LocalDateTime localStart = LocalDateTime.of(2026, 11, 15, 14, 30);
ZoneId zone = ZoneId.of("Europe/Paris");
Instant start = localStart.atZone(zone).toInstant();
The result is:
2026-11-15T13:30:00Z
For a simple meeting, I would normally persist both:
- the resolved
Instant, for execution, comparison, and ordering; - the IANA
ZoneId, to preserve the user's context and display the meeting correctly.
If the business promise is specifically “14:30 in Paris,” especially for a distant date, I would preserve the original LocalDateTime too and define what happens when time-zone rules change.
For a recurring rule, I would always keep the local intention: for example, “every Monday at 09:00 in Europe/Paris.” Each future occurrence should be calculated from that rule and the current time-zone database.
If I stored only the first UTC value and added seven days repeatedly, the event could move from 09:00 to 10:00 after a daylight-saving transition.
The strategy therefore depends on the domain:
| Use case | Values to preserve |
|---|---|
| Event that already happened | Instant |
| One scheduled occurrence | Instant + ZoneId; keep local input if needed |
| Recurring local schedule | Local rule + ZoneId; calculate each occurrence |
| Date without a moment | LocalDate |
| Daily local opening time | LocalTime + ZoneId when a zone is required |
“Store everything in UTC” becomes more accurate when expressed like this:
Store exact moments as UTC instants, but also preserve the local information when it is part of the business intention.
Daylight saving time creates invalid and ambiguous values
A local time does not always map to exactly one instant.
When clocks move forward, some local times do not exist. In Paris, 2026-03-29T02:30 is inside the spring gap.
When clocks move backward, some local times happen twice. In Paris, 2026-10-25T02:30 has two valid offsets.
The Java ZonedDateTime documentation describes these gap and overlap cases. Calling atZone() applies Java's default resolution rules, which can be convenient but too silent for a booking system.
For user-entered appointments, I would validate the value explicitly:
public Instant resolve(LocalDateTime localDateTime, ZoneId zoneId) {
List<ZoneOffset> validOffsets = zoneId.getRules()
.getValidOffsets(localDateTime);
if (validOffsets.isEmpty()) {
throw new IllegalArgumentException("This local time does not exist");
}
if (validOffsets.size() > 1) {
throw new IllegalArgumentException("This local time is ambiguous");
}
return ZonedDateTime.ofStrict(
localDateTime,
validOffsets.get(0),
zoneId
).toInstant();
}
The API can then ask the user to choose another time for a gap, or choose one of the two offsets during an overlap. This is safer than silently moving a booking.
For a less critical feature, accepting Java's default resolution may be reasonable. The important point is to make that behavior a business decision instead of an accident.
A Spring Boot API should make the contract visible
For an action that already has one exact moment, I would expose an ISO-8601 instant:
public record PaymentResponse(
UUID id,
Instant confirmedAt
) {}
For an appointment entered in local time, the request needs the local value and the zone:
public record CreateMeetingRequest(
LocalDateTime startsAt,
String timeZone
) {}
Example JSON:
{
"startsAt": "2026-11-15T14:30:00",
"timeZone": "Europe/Paris"
}
The service validates the zone and resolves the value:
ZoneId zoneId = ZoneId.of(request.timeZone());
Instant startsAt = resolver.resolve(request.startsAt(), zoneId);
The response can return the canonical instant and the original zone:
{
"startsAt": "2026-11-15T13:30:00Z",
"timeZone": "Europe/Paris"
}
This contract avoids a dangerous question: “In which time zone should the backend interpret 2026-11-15T14:30:00?” The request answers it directly.
Spring Boot provides a spring.jackson.time-zone setting for date formatting, as documented in its common application properties. I see this as a consistency setting, not as a replacement for correct domain types. A global configuration cannot turn an ambiguous LocalDateTime into an instant.
A flight booking application has two local clocks
An airline ticket is a useful example because the departure and arrival do not normally use the same time zone.
Consider a flight from Paris Charles de Gaulle to New York JFK:
Departure: 15 November 2026 at 10:30 in Europe/Paris
Arrival: 15 November 2026 at 13:15 in America/New_York
Looking only at the local values, the flight appears to last 2 hours and 45 minutes. That is wrong because each time belongs to a different local timeline.
Spring Boot must resolve each side independently:
ZonedDateTime departure = ZonedDateTime.of(
LocalDateTime.of(2026, 11, 15, 10, 30),
ZoneId.of("Europe/Paris")
);
ZonedDateTime arrival = ZonedDateTime.of(
LocalDateTime.of(2026, 11, 15, 13, 15),
ZoneId.of("America/New_York")
);
Duration flightDuration = Duration.between(
departure.toInstant(),
arrival.toInstant()
);
The resolved values are:
Departure: 2026-11-15T10:30+01:00[Europe/Paris]
2026-11-15T09:30:00Z
Arrival: 2026-11-15T13:15-05:00[America/New_York]
2026-11-15T18:15:00Z
Duration: PT8H45M
The correct duration is 8 hours and 45 minutes.
The Java documentation even uses a flight from San Francisco to Tokyo to explain why ZonedDateTime is useful. A flight is one physical journey on the instant timeline, presented through the local clocks of two airports.
This gives the application two different responsibilities:
- display departure in the origin airport's local time;
- display arrival in the destination airport's local time;
- compare, sort, and calculate duration using instants.
The passenger's current time zone is a third zone. It can be useful for reminders, but it should not replace the airport-local times printed on the ticket.
Where the offset helps
An offset is very useful for a specific flight occurrence:
2026-11-15T10:30:00+01:00
2026-11-15T13:15:00-05:00
Each value identifies an exact instant. It is self-contained, easy to exchange in ISO-8601, and can be represented by OffsetDateTime in Java.
However, the offset only describes that occurrence. It does not know that Paris changes between winter and summer time. The Java time-zone guide explains that OffsetDateTime has an offset but no regional ZoneRules, while ZonedDateTime uses the rules connected to a ZoneId.
For a flight scheduled months in advance, I would therefore use the offset as a resolved value, not as the only source of truth.
In many existing APIs, this resolved offset is stored as a number of milliseconds:
+01:00 = 3,600,000 ms
-05:00 = -18,000,000 ms
This representation is valid, but the unit and sign must be visible in the field name. A field called offset is too easy to interpret as hours, minutes, seconds, or milliseconds. I would call it offsetMilliseconds or departureOffsetMilliseconds.
On the Spring Boot side, Java's ZoneOffset.getTotalSeconds() returns seconds. The conversion to milliseconds should be explicit:
long offsetMilliseconds = Math.multiplyExact(
(long) departure.getOffset().getTotalSeconds(),
1_000L
);
The sign in this convention follows ISO and Java: zones east of UTC are positive. Angular developers must be careful with Date.getTimezoneOffset(): it returns minutes for the browser's own zone and uses the opposite sign. For example, UTC+1 returns -60. Converting it mechanically would require:
const browserOffsetMilliseconds = -date.getTimezoneOffset() * 60_000;
However, this still describes the passenger's browser, not CDG or JFK. The backend should calculate the flight offsets from the airport ZoneId and the scheduled instant.
An existing API may already use the JavaScript sign convention. In that case, backward compatibility can justify keeping it, but the contract should document it and normalize the value at one boundary. Mixing Java and JavaScript sign conventions inside the domain model is more dangerous than choosing either convention clearly.
I would preserve:
| Flight value | Purpose |
|---|---|
| Departure local date-time | Time published for the origin airport |
Departure ZoneId |
Rules of the origin airport |
Departure Instant |
Search, ordering, notifications, and execution |
| Arrival local date-time | Time published for the destination airport |
Arrival ZoneId |
Rules of the destination airport |
Arrival Instant |
Duration calculation and chronological comparison |
| Resolved offsets | API clarity or audit snapshot when required |
The airport should normally provide the zone. For example, the application's airport reference data can map CDG to Europe/Paris and JFK to America/New_York. The backend should not ask the browser to guess this mapping.
If an airline or external schedule feed sends a local date-time, zone, and offset together, Spring Boot can strictly validate the combination:
ZonedDateTime departure = ZonedDateTime.ofStrict(
localDeparture,
suppliedDepartureOffset,
departureZone
);
ofStrict() rejects an offset that is not valid for this local time and region. This can reveal stale time-zone data or an inconsistent external message.
If the system keeps the supplied offset for audit purposes, it should be stored as a snapshot of what the source published. It should not silently become the rule for every future flight from that airport.
A clear API contract for Angular
For a read response, some duplication is useful because it makes each meaning visible:
{
"flightNumber": "TU204",
"departure": {
"airport": "CDG",
"localDateTime": "2026-11-15T10:30:00",
"timeZone": "Europe/Paris",
"offsetMilliseconds": 3600000,
"instant": "2026-11-15T09:30:00Z"
},
"arrival": {
"airport": "JFK",
"localDateTime": "2026-11-15T13:15:00",
"timeZone": "America/New_York",
"offsetMilliseconds": -18000000,
"instant": "2026-11-15T18:15:00Z"
},
"duration": "PT8H45M"
}
The redundancy must be controlled by the backend. Spring Boot should generate these fields from one validated flight schedule. If an API accepts all of them as input, it must reject contradictory combinations instead of choosing one value silently.
In Angular, a JavaScript Date can represent the instant, but it does not preserve the airport's regional zone. The component should therefore format the instant with the timeZone received from the backend:
export function formatAirportTime(
instant: string,
timeZone: string,
locale: string
): string {
return new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'short',
timeZone,
}).format(new Date(instant));
}
The ECMAScript internationalization specification requires Intl.DateTimeFormat to use IANA time-zone identifiers. This makes it suitable for formatting the same instant once in Europe/Paris and once in America/New_York.
Angular's official DatePipe documentation describes its timezone argument as an offset and otherwise uses the end user's system zone. For an airline screen that must use named airport zones, I would prefer a small custom pipe or service around Intl.DateTimeFormat, or a well-maintained time-zone library, instead of depending on the browser's default zone.
The important display rule is:
Format departure with the departure airport's zone and arrival with the arrival airport's zone.
A persistence model for a flight occurrence
For PostgreSQL, a flight occurrence could keep the published local schedule and the resolved instants:
CREATE TABLE flight_occurrence (
id UUID PRIMARY KEY,
flight_number VARCHAR(12) NOT NULL,
departure_airport CHAR(3) NOT NULL,
departure_local TIMESTAMP WITHOUT TIME ZONE NOT NULL,
departure_zone VARCHAR(64) NOT NULL,
departure_offset_ms INTEGER NOT NULL,
departure_at TIMESTAMPTZ NOT NULL,
arrival_airport CHAR(3) NOT NULL,
arrival_local TIMESTAMP WITHOUT TIME ZONE NOT NULL,
arrival_zone VARCHAR(64) NOT NULL,
arrival_offset_ms INTEGER NOT NULL,
arrival_at TIMESTAMPTZ NOT NULL
);
The local columns preserve the airline schedule. The zone columns preserve the airport rules. The offset columns keep the resolved snapshot in the unit used by the API. The TIMESTAMPTZ columns support chronological queries and notification jobs.
Because the offset columns are derived data, the backend should always update them together with the local schedule, zone, and instant. Database constraints can also restrict them to Java's valid range of -64,800,000 to 64,800,000 milliseconds.
This model also makes schedule changes explicit. When an airline changes a departure time, or when time-zone data changes before the flight, the application can apply a clear update policy and recalculate the affected instants. It does not have to reconstruct the original intention from UTC alone.
Flights crossing midnight or the international date line are handled in exactly the same way. An arrival can have an earlier clock time or a different calendar date. The duration remains:
Duration.between(departureInstant, arrivalInstant);
It should never be calculated by subtracting the two airport-local LocalDateTime values.
PostgreSQL stores an instant, not the original zone
With PostgreSQL, I would use TIMESTAMPTZ for exact moments:
CREATE TABLE meeting (
id UUID PRIMARY KEY,
starts_at TIMESTAMPTZ NOT NULL,
time_zone VARCHAR(64) NOT NULL
);
The starts_at column contains the instant. The time_zone column preserves the region chosen by the user.
The name timestamp with time zone can be misleading. PostgreSQL converts the input to UTC and does not retain the original zone. When it returns the value, it converts it using the database session's current time zone.
This explains why a separate zone column is necessary when the original region matters.
For other domain values, I would choose different SQL types:
| Java type | PostgreSQL type | Typical use |
|---|---|---|
Instant |
TIMESTAMPTZ |
Event or exact moment |
LocalDate |
DATE |
Birthday or business date |
LocalTime |
TIME |
Daily local opening time |
LocalDateTime |
TIMESTAMP WITHOUT TIME ZONE |
Intentionally local value |
Hibernate recommends java.time types and provides time-zone storage strategies for OffsetDateTime and ZonedDateTime. Its ORM user guide also explains that JDBC can otherwise depend on the JVM default zone.
As an additional safeguard, a Spring Boot application can configure Hibernate's JDBC reference zone:
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
This tells Hibernate to use UTC when binding JDBC date-time values. It reduces environment-dependent behavior, but it still does not replace the correct Java and database types.
I would also run the application and database sessions in UTC where possible. UTC defaults make failures less surprising. They should be a safety net, not hidden business logic.
Inject a Clock instead of calling now() everywhere
Time-dependent code becomes difficult to test when it reads the system clock directly.
Java's Clock documentation recommends passing a clock to code that needs the current time. Spring dependency injection makes this simple:
@Configuration
class TimeConfiguration {
@Bean
Clock clock() {
return Clock.systemUTC();
}
}
The service receives the clock through its constructor:
@Service
class SubscriptionService {
private final Clock clock;
SubscriptionService(Clock clock) {
this.clock = clock;
}
boolean isExpired(Instant expiresAt) {
return expiresAt.isBefore(clock.instant());
}
}
The test can freeze time:
Clock fixedClock = Clock.fixed(
Instant.parse("2026-11-15T13:30:00Z"),
ZoneOffset.UTC
);
SubscriptionService service = new SubscriptionService(fixedClock);
There is no sleep, no race around midnight, and no dependency on the developer's laptop time zone.
The tests I would write first
Normal dates are not enough for this subject. A useful test suite should include the boundaries where assumptions fail.
I would cover at least:
- The same instant displayed in two different zones.
- A daylight-saving gap where the local time does not exist.
- A daylight-saving overlap where two offsets are valid.
- A zone without daylight saving, such as
Asia/Tokyo. - A date-only value that must not move to another day.
- JSON serialization with an explicit
Zor numeric offset. - Database read and write while the JVM and database use different default zones.
- A time-based rule using a fixed
Clock.
A focused conversion test can remain small:
@Test
void convertsAParisMeetingToItsInstant() {
LocalDateTime local = LocalDateTime.of(2026, 11, 15, 14, 30);
ZoneId paris = ZoneId.of("Europe/Paris");
Instant result = local.atZone(paris).toInstant();
assertThat(result).isEqualTo(
Instant.parse("2026-11-15T13:30:00Z")
);
}
For persistence, I would add an integration test with the real database engine. Date-time behavior is one of the areas where replacing PostgreSQL with an in-memory database can hide an important difference.
Practices I would avoid
The recommendations become clearer when compared with the common shortcuts:
- Do not use
LocalDateTimefor an event that represents an exact instant. - Do not use the server's default time zone as business context.
- Do not infer a user's time zone from the locale.
- Do not store only
+01:00when future regional rules matter. - Do not use ambiguous abbreviations such as
CSTin API contracts. - Do not append
Zto a local value without converting it to UTC. - Do not convert birthdays or other date-only values to midnight instants.
- Do not call
Instant.now()orLocalDate.now()directly throughout business code; inject aClock. - Do not assume that PostgreSQL
TIMESTAMPTZkeeps the originalZoneId. - Do not calculate recurring local events by repeatedly adding 24 hours to an instant.
I would also prefer java.time over the older Date, Calendar, and SimpleDateFormat APIs. The newer types make the meaning visible and are immutable.
The strategy I would use
After this exploration, my default approach for an international Spring Boot backend would be:
Identify the domain meaning
↓
Choose the matching java.time type
↓
Use ISO-8601 at the API boundary
↓
Store exact moments in UTC
↓
Keep the IANA zone when local intent matters
↓
Convert only at clear boundaries
↓
Test gaps, overlaps, and different defaults
The short version is:
- use
Instantfor exact moments; - use
LocalDateandLocalTimefor genuinely local concepts; - use IANA
ZoneIdvalues for regional rules; - preserve local intent for future or recurring schedules;
- use
TIMESTAMPTZplus a separate zone column when both the instant and region matter; - inject
Clockfor testable business logic; - make gaps and overlaps explicit decisions.
The hardest part of time handling is not knowing how to convert one value into another. It is resisting the temptation to convert before understanding what the value represents.
Once the domain meaning is clear, Spring Boot, java.time, Hibernate, and PostgreSQL provide the tools to keep that meaning intact.
Continue the conversation
Share your perspective
Questions, counterpoints, and production experience are welcome. A GitHub account is required to comment.