Clock Skew: Why You Can't Trust Timestamps Across Machines

architecturedistributed-systemsdotnet
Clock Skew: Why You Can't Trust Timestamps Across Machines

Clock skew is the difference between machines' clocks at the same instant. It can make timestamp sorting disagree with event order even when both services are healthy. Use serialized log positions, logical clocks, or record versions when ordering affects correctness.

For example, suppose the following events occur: Service A stamps an event with its clock. Service B stamps a later event with its clock. Your code sorts by timestamp, and B's event comes out first, because B's clock runs 40 milliseconds behind.

Even with time synchronization, timestamps cannot establish an event order when their uncertainty intervals overlap.

How Much Can Clocks Disagree?

The accuracy you can assume depends on your deployment:

  • Servers using NTP: accuracy depends on the clock source and network path. Synchronization alone is not a documented bound for your fleet.
  • Cloud VMs: host pauses, migrations, and provider-specific time sources affect clock behavior. Check the time service's guarantees for your instance type.
  • Failure cases: an unreachable NTP server, a misconfigured container host, or a VM restored from a snapshot can leave clocks far apart.
  • Steps and slews: when NTP corrects a drifted clock, it either slews (speeds/slows the clock gradually) or steps it, a discontinuous jump, possibly backward. Wall-clock time on one machine is not even monotonic.

Different leap-second smear policies can also produce different times throughout a smear window.

A timestamp is a measurement with uncertainty. Two events 5 ms apart cannot be ordered reliably if the combined clock uncertainty is larger than that interval.

Where This Bites Real Systems

Last-write-wins conflict resolution. Two services update the same record; the "later" timestamp wins. With 20 ms of skew, a write that happened first can carry a later timestamp and overwrite the actual latest data. This is a real data-loss mode in timestamp-based conflict resolution, and the overwrite produces no error or log entry.

Event ordering in projections. An event stream consumer sorts by OccurredAt to rebuild state. OrderCancelled (stamped by host A) sorts before OrderPlaced (stamped by host B), and your projection either throws or, worse, builds the wrong state without an error. I walked through the broader ordering problem in solving message ordering from first principles; clock skew is one of the reasons "just sort by time" is not on the solution list.

Two services with skewed clocks send events to a consumer that sorts by timestamp: the later OrderCancelled carries an earlier timestamp than OrderPlaced, so sorting produces the wrong order

Cache and token expiry at boundaries. A token issued by one server is validated by another whose clock runs early, and a just-issued token is "expired". Microsoft IdentityModel's default ClockSkew is five minutes. Set tolerance according to your issuer and security requirements; increasing it also extends acceptance around token lifetime boundaries.

Distributed locks and leases. "The lease is valid until 12:00:00.500" means different things on machines that disagree on what 12:00:00.500 is. Lease-based coordination must build in skew margins, which is part of why distributed locking is harder than it looks.

Measuring duration with wall clocks. This bug needs only one machine, because DateTime.UtcNow can step backward mid-measurement.

// Wrong: wall clock, can step backward or jump forward
var wallStart = DateTime.UtcNow;
await Task.Delay(100);
var wallElapsed = DateTime.UtcNow - wallStart;

// Right: monotonic clock, immune to NTP corrections
var start = System.Diagnostics.Stopwatch.GetTimestamp();
await Task.Delay(100);
var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start);

Stopwatch (and Environment.TickCount64) read a monotonic clock that only moves forward. Use wall-clock time for timestamps and Stopwatch for elapsed durations within a process.

Ordering Without Trusting Clocks

Choose an ordering guarantee that matches the operation: a log position, a causal relationship, or a version on a record.

Single-writer sequencing. Let one serialized writer assign positions when you need an authoritative sequence. An identity column is useful for generating positions, but concurrent database transactions require extra care:

CREATE TABLE outbox_messages (
    position   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload    jsonb NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now() -- for humans, not for ordering
);

PostgreSQL sequences allocate numbers outside transaction rollback semantics. Transaction A can allocate position 10, transaction B can allocate and commit 11, and A can commit later. A poller that advances its cursor to 11 can permanently miss 10.

A local PostgreSQL 18 check held the lower-ID insert open while committing a second connection's insert:

PostgreSQL identity: higher ID committed while lower ID was uncommitted, confirming cursor-skip risk.

Serialize appends through commit, use a committed log position such as a broker offset, or poll unpublished outbox rows without assuming identity values are commit order. Kafka gives each partition an append order, but producers still need to send causally related events in the required order.

Logical clocks (Lamport). When there is no single writer, you can still capture causality: every node keeps a counter, increments it on each event, attaches it to messages, and on receipt sets counter = max(local, received) + 1.

public sealed class LamportClock
{
    private long _time;

    public long Tick() => Interlocked.Increment(ref _time);

    public long Observe(long received)
    {
        long current, next;
        do
        {
            current = Interlocked.Read(ref _time);
            next = Math.Max(current, received) + 1;
        }
        while (Interlocked.CompareExchange(ref _time, next, current) != current);

        return next;
    }
}

If event X caused event Y, X's Lamport timestamp is guaranteed to be smaller than Y's, with no clock sync required. (The converse does not hold, and vector clocks exist for when you need to detect concurrency, at the cost of carrying a counter per node.)

Version numbers per record. For concurrent updates, use an integer version on the row, incremented on every write, with writes conditional on the version they read. Optimistic concurrency control detects a stale update; your application must then reject, merge, or retry it.

What About GUID v7 and Time-Ordered IDs?

Guid.CreateVersion7(), available since .NET 9, embeds a Unix millisecond timestamp. It can improve index locality when the database's GUID comparison order matches the UUID layout. I covered it in GUID v7 in .NET.

But the IDs are only roughly sortable. IDs minted on different machines inherit those machines' skew, and within one millisecond ordering is random. Use v7 GUIDs for index-friendly identifiers, not as a distributed ordering primitive. The same verdict applies to any "timestamp + random bits" scheme (ULID, Snowflake without coordinated clocks).

Practical Rules for .NET Systems

  • Stamp everything in UTC, and prefer DateTimeOffset over DateTime so the offset is explicit; see DateTime vs DateTimeOffset.
  • Durations: monotonic only. Stopwatch.GetElapsedTime, Environment.TickCount64, or TimeProvider.GetTimestamp().
  • Inject time. Route time access through .NET 8's TimeProvider instead of static DateTime.UtcNow calls. Beyond testability (covered in TimeProvider in .NET), it gives you a single place to apply time policy.
  • Define time assumptions at trust boundaries. Monitor offset and synchronization failures. Token acceptance may need a tolerance; lease holders need conservative deadlines, because extending a lease locally would make stale leadership last longer.
  • Avoid cross-machine timestamps as the sole correctness check. For deciding which write wins or how events are ordered, prefer versions, serialized log positions, or logical clocks. Time-based protocols need explicit error bounds and failure handling.

Summary

  • Synchronized clocks can still disagree, and wall-clock corrections can move time backward.
  • Do not infer an exact cross-machine order from timestamps whose uncertainty overlaps.
  • Get ordering from serialized writers or log offsets, causal ordering from logical clocks, and conflict detection from version numbers.
  • In .NET specifically: DateTimeOffset in UTC for "when", Stopwatch/TimeProvider.GetTimestamp for "how long", and TimeProvider as the seam that keeps time testable.

Test with skewed and backward-moving wall clocks. Your ordering and concurrency tests should still pass without depending on the local machine's current time.

Frequently Asked Questions

What is clock skew?

Clock skew is the difference between the clocks of two machines at the same instant. Its size depends on the clock source, network, and host configuration, and it can grow when synchronization fails. Timestamp comparisons across machines inherit that uncertainty.

Does NTP solve clock skew?

NTP reduces clock skew but does not establish a universal maximum error. Accuracy depends on the clock source, network, and host configuration. Monitor synchronization and offset instead of assuming all machines agree closely enough to order events.

How should I order events in a distributed system if not by timestamp?

Use a serialized writer or partitioned log for an authoritative order, Lamport counters to preserve causal ordering, or version numbers per aggregate for concurrency control. A database identity alone orders number allocation, not transaction commits.

Why do I get negative elapsed time measurements in C#?

DateTime.UtcNow is not monotonic: NTP corrections can step it backward. Subtracting two wall-clock readings across such a step yields negative or inflated durations. Use Stopwatch or Environment.TickCount64 for measuring elapsed time, since they use a monotonic clock that never goes backward.

  • The CAP Theorem for Working Developers

    During a network partition, distributed systems cannot guarantee both linearizable operations and availability to every non-failed node. Learn how that limit affects PostgreSQL replication, read routing, and the guarantees your .NET application needs for each operation.

  • Data Partitioning and Sharding Strategies for .NET

    Sharding splits data across databases so writes and storage can scale beyond one server. The shard key decides which queries stay local and which must query several databases. Compare hash and range routing, build a stable .NET router, and account for cross-shard queries before migrating data.

  • Amazon SQS With .NET: A Practical Guide

    Amazon SQS removes the broker from your operations burden, but reliable consumers still depend on visibility timeouts, explicit deletes, long polling, and a dead letter policy. This guide builds that baseline in .NET with the AWS SDK, FIFO queues, batch operations, and MassTransit.

Browse all Distributed Systems guides →
Loading comments...

Whenever you're ready, there are 5 ways I can help you:

  1. Pragmatic Self-Hosting: Learn to deploy and run your apps on a VPS with Dokploy and Tailscale. Keep administration private, automate releases, and practice restoring backups, with source code and a runbook you can reuse.
  2. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  3. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  4. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  5. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

One practical .NET tip, every Saturday.

Join 66,000+ engineers who read it. Free, and you can unsubscribe any time.