Handling Clock Skew in Last-Modified Validation

An origin, a CDN edge node, and a client browser each keep their own system clock. Date-based conditional validation depends on all three agreeing, at least approximately, on what time it currently is — the entire mechanism is built on comparing timestamps generated by different machines. When those clocks disagree by more than a trivial amount, Last-Modified comparisons produce wrong answers: content that should revalidate as unchanged gets refetched unnecessarily, content that changed gets served stale, and in the worst case a cache enters a loop of alternating fresh and stale verdicts for the same resource.

Prerequisite Concepts

Before diagnosing skew-related failures, make sure you’re grounded in the underlying mechanism:

Step-by-Step Resolution

Step 1 — Compare Date headers across every tier

Every HTTP response carries a Date header stamped by whichever server generated that specific response. Query the origin directly, bypassing the CDN, and compare it to a query through the CDN:

# Direct to origin (bypass CDN with Host header + direct IP, or a staging hostname)
curl -sI -H "Host: example.com" https://origin.internal.example.com/page \
  | grep -i '^date'

# Through the CDN
curl -sI https://example.com/page | grep -i '^date'

# Your own reference clock, in the same format
date -u "+%a, %d %b %Y %H:%M:%S GMT"

Line up all three timestamps. A difference of more than a few seconds between the origin’s Date and your reference clock indicates the origin itself is skewed. A difference between the CDN’s Date and the origin’s indicates the edge node’s clock — or its response caching of the Date header — is the problem instead.

Step 2 — Check for a future-dated Last-Modified

Compare Last-Modified to Date on the same response. Last-Modified should always be at or before Date — a resource cannot have been last modified in the future relative to the moment it was served:

curl -sI https://example.com/page | grep -iE '^(date|last-modified):'
Date: Sun, 06 Jul 2026 09:00:00 GMT
Last-Modified: Sun, 06 Jul 2026 09:04:12 GMT

In this example, Last-Modified is four minutes after Date — a clear signature of a skewed application server clock (the process writing the timestamp is running ahead of the machine generating the HTTP Date header, or ahead of the reverse proxy in front of it). This is the single most reliable, low-effort signal that clock skew — not application logic — is the root cause of validation misbehavior.

Step 3 — Reproduce the revalidation loop

A classic skew symptom is a cache that alternates between serving stale content and refetching unnecessarily on every single request, never settling into a stable revalidate-and-304 pattern. Reproduce it directly:

LM=$(curl -sI https://example.com/page | grep -i '^last-modified' | sed 's/^[Ll]ast-[Mm]odified: //' | tr -d '\r')

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "If-Modified-Since: $LM" \
    https://example.com/page
  sleep 2
done

If a stable, unchanged resource returns 200 on some iterations and 304 on others with no content change in between, the comparison is unstable — almost always because the skew is close to a boundary that flips the “is this timestamp newer” comparison depending on sub-second jitter or an intermediate proxy re-stamping Date on each pass.

Step 4 — Synchronize clocks with NTP

The durable fix is time synchronization, not application-layer workarounds. On Linux origins, chrony is the modern standard:

# Install and enable chrony
sudo apt-get install -y chrony
sudo systemctl enable --now chronyd

# Confirm synchronization status
chronyc tracking

Expected output includes a small System time offset (well under a second) and Leap status: Normal:

Reference ID    : C0248F01 (ntp1.example.net)
Stratum         : 3
System time     : 0.000042 seconds fast of NTP time
Leap status     : Normal

On systems still using ntpd:

sudo systemctl enable --now ntpd
ntpq -p

Look for a * prefix next to the selected reference server, and a offset column value close to zero. Apply the same check to any intermediate reverse proxy, load balancer, or self-hosted cache in the path — not just the origin — since any tier that regenerates Date or Last-Modified is a potential skew source.

Step 5 — Add ETag as a skew-immune fallback validator

Even after NTP synchronization, treat date-based validation as inherently fragile across trust boundaries you don’t control (a CDN’s fleet of edge nodes, a client’s local clock which you can never fix). Emit an ETag alongside Last-Modified so revalidation no longer depends on clock agreement at all:

HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
Last-Modified: Sun, 06 Jul 2026 09:00:00 GMT
ETag: "d41d8cd98f00b204e9800998ecf8427e"

ETag comparison is pure string equality against an opaque token — no date parsing, no timezone handling, no notion of “before” or “after” is involved. A client or CDN that sends both If-None-Match and If-Modified-Since will have its request resolved by If-None-Match first per RFC 9111 §4.3.1, making the skew in Last-Modified irrelevant to the outcome. See strong vs weak ETags explained for choosing the right ETag strength for your resource type.

Expected Output / Verification

After synchronizing clocks and adding an ETag fallback, confirm the fix holds:

# Date headers should now agree within ~1 second across tiers
curl -sI -H "Host: example.com" https://origin.internal.example.com/page | grep -i '^date'
curl -sI https://example.com/page | grep -i '^date'
date -u "+%a, %d %b %Y %H:%M:%S GMT"
Date: Sun, 06 Jul 2026 09:12:44 GMT
Date: Sun, 06 Jul 2026 09:12:45 GMT
Sun Jul  6 09:12:45 UTC 2026

A one-second difference here is expected transport latency, not skew. Re-run the revalidation loop from Step 3 — the response code should now be stable 304 across every iteration for an unchanged resource:

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "If-None-Match: \"d41d8cd98f00b204e9800998ecf8427e\"" \
    -H "If-Modified-Since: $LM" \
    https://example.com/page
done

Expect 304 304 304 304 304. If any iteration still returns 200 with byte-identical content, the ETag comparison itself is misconfigured (e.g. weak/strong mismatch across a compressing proxy) rather than a clock problem — see ETag vs Last-Modified: which validator to use.

Edge Cases

  • CDN edge nodes with independently drifted clocks — even a perfectly synchronized origin can sit behind a CDN whose individual edge PoPs have not all converged to the same NTP source. Test through multiple geographic edges (using a CDN’s regional test endpoints, if available) rather than assuming one PoP’s behavior generalizes.
  • Virtualized/containerized hosts with clock drift — VMs and containers frequently drift faster than bare metal because their clocks depend on the hypervisor’s timer, which can pause during host-level CPU contention. Confirm the host’s NTP daemon, not just the container’s, is active.
  • Expires − Date arithmetic going negative — when an origin still emits a legacy Expires header (rather than max-age) and its clock runs fast, Expires − Date can compute a negative or near-zero freshness lifetime even for content that should be fresh for hours. Prefer max-age precisely because it is skew-immune — it is a relative duration, not two absolute timestamps subtracted from each other. See how to calculate cache freshness lifetime for the full precedence order.
  • Daylight saving / timezone bugs masquerading as skew — HTTP-date is always GMT and has no daylight-saving component, but an application that constructs Last-Modified from a local-timezone timestamp without converting to GMT first will appear to have one to several hours of “skew” that is actually a formatting bug, not a clock problem. Rule this out by checking whether the offset is a round number of hours.

Frequently Asked Questions

How much clock skew actually causes problems?

Any nonzero skew can theoretically cause a one-second boundary miss, but in practice skew under roughly one second rarely produces visible symptoms. Skew in the range of tens of seconds to minutes routinely causes revalidation loops, incorrect Expires math, and stale content served past its intended lifetime.

Why is ETag immune to clock skew but Last-Modified is not?

ETag validation compares an opaque content fingerprint using string equality — no clock, timezone, or date parsing is involved on either side. Last-Modified validation requires the origin to compare two timestamps, and that comparison is only meaningful if both parties agree on what time it currently is.

Can a CDN cause clock skew even if my origin’s clock is correct?

Yes. The CDN edge node generates its own Date header for cached responses and may run on hardware with its own drifted clock, independent of the origin. Always test the Date header at every tier separately rather than assuming origin correctness implies edge correctness.

Does HTTPS or TLS time validation fix this problem?

No. TLS certificate validity checks use the client’s own clock against certificate notBefore/notAfter fields and are unrelated to HTTP-date comparisons inside Last-Modified and Date headers. A server can have perfectly valid TLS certificates while still emitting skewed caching headers.


Back to Last-Modified and Date-Based Validation