A One File PWA to Tell You When Time Is Lying

I have lost more hours than I want to admit to problems that turned out not to be the network or the app or the API, but just bad time.

We already have NTP, PTP (IEEE-1588), linuxptp, real grandmasters, fancy NICs. That’s not what this is about. I wanted a super cheap way for a person holding an Android/iPad/laptop to answer exactly one question:

Is it me or is it the edge box?

So I built a single-file web app for that.

It’s just one HTML file you can stick in GitHub, serve from nginx on the local gateway, and tell a tech:

Open /probe and if it’s green, it’s not you.

This is not a PTP/IEEE-1588 monitor and it will not give you sub-µs accuracy. It also won’t prove the server is telling the truth.

It is just a browser-level sanity probe. It compares Date.now() (client) to /time (server), draws a tiny sparkline, and goes green/red. Because it’s a PWA you can "Add to Home Screen" and keep it around.

In short, it tells you:

You and this server disagree by ~X ms and the disagreement is / isn’t stable.

That’s enough to stop chasing ghosts.

Field people already have a browser. They can hit http://192.168.10.1/time. They can bookmark a page. This can run in places where you can’t run linuxptp but you can still be confused by time drift.

The /time endpoint

Server side is just a dumb route that returns wall time (system CLOCK_REALTIME) as JSON, with CORS open so phones can call it:

{ "epoch_ms": 1730615400000 }

That’s it.

The one-file PWA

Here’s the whole thing:

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Edge Time Probe (Debug)</title>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <style>
      body { font-family: system-ui, sans-serif; margin: 1.5rem; }
      h1 { font-size: 1.25rem; margin-bottom: .5rem; }
      #status { font-weight: 600; }
      #status.good { color: #0a0; }
      #status.bad { color: #c00; }
      canvas { border: 1px solid #ddd; margin-top: .75rem; }
      small, #diag, #err { color:#555; display:block; margin-top:.5rem; }
      #err { color:#a00; white-space:pre-wrap; }
      code { background:#f6f6f6; padding:.1rem .25rem; border-radius:4px; }
    </style>
  </head>
  <body>
    <h1>Edge Time Probe</h1>
    <p>Offset vs <code id="ep">/time</code>: <span id="offset">–</span> ms <span id="status"></span></p>
    <canvas id="spark" width="300" height="60"></canvas>
    <div id="diag"></div>
    <div id="err"></div>
    <small>Green = |offset| ≤ 50ms and jitter is low. This is a sanity check, not PTP.</small>

    <script>
      // ===== config =====
      const DEFAULT_ENDPOINT = '/time';
      const params = new URLSearchParams(location.search);
      const ENDPOINT = params.get('endpoint') || DEFAULT_ENDPOINT;
      const INTERVAL_MS = 3000, THRESH_MS = 50, MAX_SAMPLES = 60;

      // ===== elements =====
      const epEl = document.getElementById('ep');
      const offsetEl = document.getElementById('offset');
      const statusEl = document.getElementById('status');
      const errEl = document.getElementById('err');
      const diagEl = document.getElementById('diag');
      const canvas = document.getElementById('spark');
      const ctx = canvas.getContext('2d');

      // ===== state =====
      const samples = [];
      const endpointURL = new URL(ENDPOINT, location.href);
      const sameOrigin = endpointURL.origin === location.origin;

      epEl.textContent = sameOrigin ? endpointURL.pathname : endpointURL.href;
      diagEl.textContent =
        `Page origin: ${location.origin}
Endpoint:    ${endpointURL.href}
Same-origin: ${sameOrigin}
Protocol:    page=${location.protocol} endpoint=${endpointURL.protocol}`;

      function toEpochMs(json) {
        const c = [json?.epoch_ms, json?.epochMs, json?.now_ms, json?.nowMs, json?.epoch, json?.now, json?.time];
        let v = c.find(x => x !== undefined);
        v = typeof v === 'string' ? Number(v) : v;
        if (!Number.isFinite(v)) return NaN;
        if (v < 1e12) v *= 1000;
        return v;
      }

      async function poll() {
        const t0 = performance.now();
        try {
          // mode 'cors' is default for cross-origin; explicit for clarity
          const res = await fetch(endpointURL.href, {
            cache: 'no-store',
            mode: sameOrigin ? 'same-origin' : 'cors',
            headers: { 'accept': 'application/json' },
            redirect: 'follow',
          });

          if (!res.ok) throw new Error(`HTTP ${res.status}`);

          let json;
          try { json = await res.json(); }
          catch {
            const txt = await res.text().catch(() => '');
            throw new Error(`Bad JSON (first 200 chars): ${txt.slice(0,200)}`);
          }

          const server = toEpochMs(json);
          if (!Number.isFinite(server)) throw new Error('Missing numeric epoch_ms (ms) in response');

          const t1 = performance.now();
          const rtt = t1 - t0;
          const client = Date.now();
          const offset = server - client + rtt / 2;

          errEl.textContent = '';
          record(offset);
        } catch (e) {
          // Common root causes, surfaced as hints
          const hints = [];
          if (!sameOrigin) {
            hints.push(
              `• If you see a CORS error in the console, add this header on ${endpointURL.origin}:\n  Access-Control-Allow-Origin: ${location.origin}`
            );
          }
          if (location.protocol === 'https:' && endpointURL.protocol === 'http:') {
            hints.push('• Mixed content: HTTPS page cannot fetch HTTP endpoint. Serve the endpoint over HTTPS.');
          }
          if (endpointURL.href.endsWith('/time') === false && endpointURL.pathname === '/time' === false) {
            hints.push('• Verify the endpoint path is correct and does not redirect.');
          }
          errEl.textContent = `Failed to fetch: ${e.message}\n` + (hints.join('\n') || '');
          console.error('Time probe error:', e);
          record(NaN);
        }
      }

      function record(offset) {
        samples.push(offset);
        if (samples.length > MAX_SAMPLES) samples.shift();
        render();
      }

      function render() {
        const last = samples[samples.length - 1];
        if (Number.isNaN(last)) {
          offsetEl.textContent = 'error';
          statusEl.textContent = '(no data)';
          statusEl.className = 'bad';
        } else {
          offsetEl.textContent = last.toFixed(1);
          const jitter = computeJitter(samples);
          const good = Math.abs(last) <= THRESH_MS && jitter <= THRESH_MS;
          statusEl.textContent = good ? '(ok)' : '(drifting)';
          statusEl.className = good ? 'good' : 'bad';
        }

        ctx.clearRect(0, 0, canvas.width, canvas.height);
        if (samples.length === 0) return;
        const finite = samples.filter(x => !Number.isNaN(x));
        const min = Math.min(...finite, -THRESH_MS);
        const max = Math.max(...finite, THRESH_MS);
        ctx.beginPath();
        samples.forEach((v, i) => {
          const x = (i / Math.max(1, samples.length - 1)) * canvas.width;
          const norm = Number.isNaN(v) ? 0 : (v - min) / (max - min || 1);
          const y = canvas.height - norm * canvas.height;
          if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
        });
        ctx.stroke();
      }

      function computeJitter(arr) {
        const finite = arr.filter(x => !Number.isNaN(x));
        if (finite.length < 2) return 0;
        const avg = finite.reduce((a, b) => a + b, 0) / finite.length;
        return Math.sqrt(finite.reduce((a, b) => a + (b - avg) ** 2, 0) / finite.length);
      }

      // minimal SW so phones let you install (won't affect fetch)
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker
          .register('data:application/javascript,oninstall%3De%3D%3Eself.skipWaiting()%3Bonactivate%3De%3D%3Eclients.claim()%3B')
          .catch(() => {});
      }

      poll();
      setInterval(poll, INTERVAL_MS);
    </script>
  </body>
</html>

But… latency?

We used:

offset = server_time - client_time + rtt/2;

That assumes symmetrical latency (15ms there, 15ms back). On the public internet, LTE, satellite, or weird SD-WAN, that’s not true. On a flat site network close to the gateway? Often good enough.

So on asymmetric links this will over- or under-estimate. The goal is to tell a human "this clock is obviously off," not to discipline a PHC.

Why not just use PTP?

Because browsers can’t.

PTP (IEEE-1588) is usually at a lower layer, often needs hardware timestamping, and is meant to actually synchronize clocks to sub-ms/sub-µs. Our little PWA is just a viewer.

If this thing says you’re off by 200ms, go to the box and run linuxptp/NTP diagnostics. This PWA is the canary, not the mine.

Real-world usage

  • put the HTML at http://edge-gw.local/probe.html
  • put /time at http://edge-gw.local/time
  • tell techs: "open probe → if red, screenshot and send"
  • you get: offset, rough jitter, and proof they were on the right gateway

No VPN, no secrets.


This isn’t meant to win you a timing competition. It’s meant to shorten tickets by telling you fast whether the client clock is wonky.

Sometimes that’s all the "edge AI" you need :)


Read more