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-FR helps format a date in French for France;
  • Europe/Paris provides 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:

  1. The same instant displayed in two different zones.
  2. A daylight-saving gap where the local time does not exist.
  3. A daylight-saving overlap where two offsets are valid.
  4. A zone without daylight saving, such as Asia/Tokyo.
  5. A date-only value that must not move to another day.
  6. JSON serialization with an explicit Z or numeric offset.
  7. Database read and write while the JVM and database use different default zones.
  8. 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 LocalDateTime for 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:00 when future regional rules matter.
  • Do not use ambiguous abbreviations such as CST in API contracts.
  • Do not append Z to 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() or LocalDate.now() directly throughout business code; inject a Clock.
  • Do not assume that PostgreSQL TIMESTAMPTZ keeps the original ZoneId.
  • 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 Instant for exact moments;
  • use LocalDate and LocalTime for genuinely local concepts;
  • use IANA ZoneId values for regional rules;
  • preserve local intent for future or recurring schedules;
  • use TIMESTAMPTZ plus a separate zone column when both the instant and region matter;
  • inject Clock for 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.