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
/timeathttp://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 :)