Implementing NTP for Network Time Synchronization in Java

Part 7 of 11 in Mastering Java Network Programming

NTP (Network Time Protocol) timestamps are a deceptively simple format hiding two pitfalls that trip up even experienced engineers: an epoch offset no one remembers by heart, and a 32-bit fixed-point fraction field that doesn’t map cleanly to nanosecond precision.

The code

Epoch mismatch

NTP was designed in the late 1980s. Its epoch starts at January 1, 1900 — not 1970 like Unix, which later became Java’s default.

The gap between those two dates is exactly 2208988800 seconds (70 years of 365 days plus 17 leap days). That number doesn’t factor neatly into anything, so it shows up as a hard-coded constant:

private static final long NTP_EPOCH_OFFSET = 2208988800L;

If you forget the offset, subtract it, or use the wrong sign, your parsed time will be off by decades.

The timestamp format

An NTP timestamp is 8 bytes on the wire — the “four-byte network time” the protocol operates on, extended with a fraction field:

  • Bytes 0–3: seconds since January 1, 1900 (unsigned 32-bit integer, big-endian)
  • Bytes 4–7: fractional seconds (32-bit fixed-point — value from 0 to 2^32-1 representing the fraction of the current second)

The core conversion reads both fields and applies two transformations:

public static Instant parseNtpTimestamp(ByteBuffer buf) {
    long secondsSince1900 = buf.getInt() & 0xFFFFFFFFL;
    long fraction = buf.getInt() & 0xFFFFFFFFL;

    long nanos = (long) ((fraction * 1_000_000_000L) >>> 32);
    long unixSeconds = secondsSince1900 - NTP_EPOCH_OFFSET;

    return Instant.ofEpochSecond(unixSeconds, nanos);
}

buf.getInt() returns a signed int — the & 0xFFFFFFFFL mask converts any value with its high bit set (above 2^31-2) to an unsigned long. The fractional conversion multiplies by 1e9 and right-shifts by 32 bits (division by 2^32), extracting nanosecond precision from the base-2 fraction.

For standalone four-byte NTP seconds fields encountered in packet captures or logs, the offset subtraction is all that’s needed:

long wireValue = ByteBuffer.wrap(ntpBytes).getInt() & 0xFFFFFFFFL;
long unixEpoch = ntpSecondsToUnix(wireValue);

Building timestamps back to the wire

The reverse direction — converting a Java Instant back to NTP format — requires packing nanoseconds into 32-bit fixed-point:

int fraction = (int) ((instant.getNano() * 0x1_00000000L) / 1_000_000_000L);

Division by 1e9 (not a bit-shift) is essential here: multiplying nanoseconds by 2^32 and then dividing by 1e9 correctly scales [0, 1e9-1] into [0, 2^32-1]. A naive shift would produce wildly wrong results.

Running it

The source file demonstrates all three operations — epoch offset verification, a round-trip ByteBuffer parse, and the four-byte wire format:

Three facts from the output deserve emphasis:

Y2K is a perfect round-trip anchor. The date 2000-01-01T00:00:00Z has Unix epoch seconds 946684800 and NTP seconds 3155673600. Converting forward (Unix + offset = NTP) and backward (NTP - offset = Unix) produces an exact match: round-trip match: true.

The four-byte wire conversion is deterministic. The byte sequence 1B E1 5A E8 reads as uint32 467753704. Subtracting the offset gives -1741235096 Unix seconds, which maps to date 1914-10-28T19:35:04Z. The negative result confirms this byte pattern represents time before the Unix epoch — nothing is wrong, it’s just a pre-1970 timestamp.

The round-trip build-parse shows sub-microsecond accuracy. Between buildNtpTimestamp(now) and parseNtpTimestamp(buf), the parsed nanoseconds differ from the original by only 1 ns — the cost of program execution time, not conversion imprecision. The fixed-point math is precise enough that fractional seconds survive an encode-decode cycle.

A note about the 2036 rollover

NTP’s seconds field is only 32 bits unsigned, meaning it wraps around after ~year 2036 (at value 4294967295). When that happens, timestamps in the low 32 bits still decode correctly if you apply the offset — but a system that interprets the raw 32-bit value without knowing to add 2208988800 will read it as a date in the 1970s.

This is why Java should always store timestamps internally as long and only cast to int when writing to or reading from the network wire format.

Takeaway

NTP timestamps map to Java Instant with two steps: subtract 2208988800 from the upper four bytes to shift the epoch from 1900 to 1970, and multiply the lower four bytes by 1e9 then right-shift by 32 bits to extract nanosecond precision. The constant doesn’t need memorization — just a clearly named field in your utility class and a Y2K round-trip test that proves it.