Spectre
// PUBLISHED31.10.26
// TIME6 MINS
// TAGS
#GOLANG#TIME#DISTRIBUTED SYSTEMS#GOTCHA
// AUTHOR
Spectre Command

Go Gotcha #7: Your Timestamps Are Lying and NTP Is Why

Meta: "time.Now() in Go isn't as reliable as you think. Here's why NTP drift breaks distributed systems and how to stop trusting wall clocks."


Y

our payment service processed the same transaction twice. The idempotency check failed silently. The saga coordinator replayed a step it thought hadn't run yet. Three days later, your team found the root cause: two Go services on different machines had clocks that disagreed by 340 milliseconds, and that was enough to break the ordering assumption buried in your event log.

This is the timestamp gotcha. It's quiet. It shows up in production. And time.Now() is not the safe default you think it is.


What time.Now() Actually Returns

Go's time.Time struct carries two clocks internally: a wall clock reading and a monotonic clock reading.

The wall clock is what you think of as "the current time" it's synchronized to real-world time via NTP. The monotonic clock is a counter that only moves forward from the moment the process started. It exists specifically so that local duration measurements don't get distorted by clock corrections.

When you write time.Since(start), Go uses the monotonic component. That's accurate. When you compare two time.Time values with Before() or After(), Go prefers the monotonic component but only if both values came from the same process and neither has been marshalled and unmarshalled in between.

The moment you write a timestamp to your database, send it over the wire, or serialize it to JSON, the monotonic component is stripped. What comes back from your PostgreSQL row is a pure wall clock reading. Now you're comparing wall clock against wall clock across potentially different machines.

That's where NTP enters the picture.


NTP Drift Is Real, and It's Bigger Than You Think

NTP (Network Time Protocol) keeps your servers' clocks synchronized to a reference time source. It does this by periodically adjusting the local clock, and there are two ways it does the correction:

Slew is a gradual adjustment the clock is sped up or slowed down slightly until it catches up. During slew, your clock is technically lying. It's moving at a different rate than real time.

Step is a jump the clock is set directly to the correct value. After a step, timestamps before and after the correction are not comparable in a meaningful way. The clock moved backward or forward suddenly.

On a quiet, lightly loaded server this is rarely a problem. On AWS EC2 instances under CPU stress, inside Kubernetes pods with noisy neighbors, or on VMs that just resumed from a snapshot, you can see drift of dozens to hundreds of milliseconds. We've seen drift reach over a second in poorly configured environments.

For a single-node system this usually doesn't matter. For any distributed system where you're using timestamps to establish ordering across machines, it matters a lot.


Where This Specifically Bites Go Services

Go has a few patterns that make this gotcha worse than it might be in other languages.

Marshalling strips the monotonic clock. time.Time values that pass through encoding/json, database/sql, or proto lose their monotonic component. This is documented behavior, but developers frequently forget it. The value looks identical when printed the wall clock portion is preserved but t1 == t2 can return false even for timestamps that represent the same moment, if one came from the database and one was just created.

time.Now().Round(0) strips it manually. Some codebases do this intentionally to normalize time values before storage. That's correct. But it's easy to miss, especially in a codebase you inherited.

Struct comparison breaks silently. If you embed time.Time in a struct and compare structs with ==, a wall-clock time and a monotonic time for the same moment will not be equal. No panic. No error. Just wrong.

The practical failure modes are: idempotency windows that overlap incorrectly, distributed locks that expire at the wrong moment, event logs where "happened-before" ordering is wrong, and saga steps that replay because a timeout check compared a fresh time.Now() against a stored wall clock timestamp from a different machine.


The Part Most People Get Wrong

The common fix people reach for is "just use UTC consistently." That fixes timezone issues. It does not fix clock drift.

Two servers both using UTC can still disagree by hundreds of milliseconds. UTC is a standard for representing time; NTP is the mechanism that keeps clocks accurate, and NTP is not perfect.

The second mistake is using time.Now().UnixNano() as a unique event ID in a distributed system. At nanosecond resolution, two events on two different machines can get the same timestamp if the clocks are close enough, or get inverted timestamps if there's been any NTP correction. This is especially dangerous for event sourcing or audit logs where ordering matters for correctness, not just display.

If you need ordering guarantees across machines, wall clocks alone won't give you that. You need logical clocks Lamport timestamps or hybrid logical clocks where each event's timestamp is the maximum of the local wall clock and the highest timestamp seen from any other node, incremented. These preserve ordering even when wall clocks drift.


Real-World Example

A fintech team in Jakarta was building a disbursement saga a multi-step process that moved money between accounts and recorded each state transition with a timestamp. The saga coordinator lived on one service. The wallet service that processed the transitions lived on another.

The bug: after an NTP step correction on the wallet service, a handful of transition timestamps jumped backwards by about 200ms. The saga coordinator, checking whether a step had expired using storedTimestamp.Before(time.Now()), saw timestamps that appeared to be "in the future" relative to the coordinator's clock. It treated those steps as not yet complete and triggered a replay.

The replay created duplicate transactions. The idempotency check relied on the same timestamps as unique keys, so it didn't catch the duplicates.

The fix was three things: switching to Hybrid Logical Clocks for event ordering (so that causal ordering was preserved regardless of wall clock drift), using UUIDs generated at the origin service as idempotency keys instead of timestamps, and adding NTP monitoring with alerts when drift exceeded 50ms.

The NTP monitoring surfaced two other services with chronic drift they hadn't noticed.


FAQ

Q: Should I stop using time.Now() in Go services entirely? A: No. time.Now() is fine for local duration measurements, logging, and anything that doesn't cross a machine boundary. The problem is using wall clock timestamps to establish ordering or identity across distributed nodes. For that, you need something stronger.

Q: Does AWS handle NTP automatically for EC2 instances? A: Yes, AWS runs a highly accurate NTP service (Amazon Time Sync Service) reachable at 169.254.169.123. It's enabled by default on most AMIs. But Kubernetes pods, especially those on heavily scheduled nodes, can still see meaningful drift particularly after node restarts or live migrations. Don't assume it's accurate without monitoring.

Q: What's a Hybrid Logical Clock and do I actually need one? A: An HLC combines a wall clock with a logical counter. Each event's timestamp is the max of the local wall time and the highest HLC seen from any message, plus a counter to break ties. You need it when you're building event sourcing, CRDTs, or any system where "happened-before" ordering has to be preserved across nodes. If you're just adding timestamps to logs for human readability, you don't need one.

Q: How do I detect NTP drift in production? A: Monitor the output of chronyc tracking or ntpq -p on your servers. AWS CloudWatch doesn't expose clock offset by default, but you can instrument it with a custom metric. Set an alert if offset exceeds 50ms at that point, distributed ordering assumptions start breaking down in ways that are hard to debug after the fact.

Q: What's the safest idempotency key for distributed transactions in Go? A: A UUID generated at the origin of the request not derived from a timestamp. Store it in the initial request payload and thread it through every downstream service. The idempotency key should be stable across retries and independent of when any given service processes the request.


Timestamps feel like infrastructure. They're not they're a distributed systems problem disguised as a standard library call. If you're building sagas, event sourcing, or any workflow that coordinates across services, treat clock synchronization as a first-class architectural concern and monitor it like you monitor latency.

The teams we work with who get this right are the ones who surface it before it breaks something financial.


Internal links used:

External links used:

  • None all claims are based on Go standard library documentation and distributed systems fundamentals

Word count: 1,310

// END_OF_LOGSPECTRE_SYSTEMS_V1

Is your current architecture slowing you down?

Stop guessing where the bottlenecks are. We partner with founders and CTOs to audit technical debt and execute zero-downtime system rewrites.

Book an Architecture Audit