The simulation below shows a local clock that drifts away from the true reference time and a simulated NTP server correcting it. Each NTP exchange sends a request, waits for the reply, measures the round-trip time, and uses half of it to estimate the one-way network delay — then adjusts the local clock by the computed offset.
<!-- Main layout — two clock displays, offset history chart, info row, buttons -->
<div class="panel" id="panel">
<div class="clocks">
<div class="clock-box">
<div class="clock-label">Reference (Server)</div>
<div class="clock-display" id="ref-display">00:00:00.000</div>
<div class="clock-sub">Atomic clock — always accurate</div>
</div>
<div class="clock-box local-box">
<div class="clock-label">Local Clock</div>
<div class="clock-display" id="local-display">00:00:00.000</div>
<div class="clock-sub" id="offset-display">offset unknown</div>
</div>
</div>
<div class="timeline" id="timeline" title="Offset history per NTP exchange — green=close, yellow=moderate, red=large">
<canvas id="canvas" width="560" height="90"></canvas>
</div>
<div class="info-row">
<span id="info-text" class="info-text">Press "Sync once" to perform an NTP exchange.</span>
</div>
<div class="btns">
<button id="btn-sync" type="button">Sync once</button>
<button id="btn-auto" type="button" class="ghost">Auto-sync</button>
<button id="btn-reset" type="button" class="ghost">Reset drift</button>
</div>
</div>
/* Base layout and clock box styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #1d2733; background: transparent; }
.panel { padding: .8rem; }
.clocks { display: flex; gap: 1rem; margin-bottom: .7rem; }
.clock-box { flex: 1; background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 10px;
padding: .6rem .8rem; text-align: center; }
.local-box { background: #fff7e6; border-color: #f0c060; }
.clock-label { font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #556; margin-bottom: .25rem; }
.clock-display { font: 700 1.35rem ui-monospace, monospace; letter-spacing: .04em; }
.clock-sub { font-size: .72rem; color: #667; margin-top: .2rem; min-height: 1em; }
/* Timeline canvas styles */
.timeline { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden;
background: #f5f8fb; cursor: default; margin-bottom: .5rem; }
canvas { display: block; width: 100%; }
/* Info row styles */
.info-row { min-height: 1.6em; margin-bottom: .5rem; }
.info-text { font-size: .85rem; color: #334; }
.info-text.synced { color: #0a7d33; font-weight: 600; }
.info-text.bad { color: #c92f3c; font-weight: 600; }
/* Button styles */
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #0a7d33; border-color: #0a7d33; color: #fff; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* NTP core: four timestamps T1..T4 from one round trip */
/* offset theta = ((T2-T1)+(T3-T4))/2 | delay delta = (T4-T1)-(T3-T2) */
/* Simulation state — refTime=wall clock ms, localOffset=how far local clock is off */
const MS = 1;
let refTime = 0;
let localOffset = 0;
let driftRate = 0;
let lastTick = null;
let autoTimer = null;
let isAuto = false;
const history = [];
const MAX_HISTORY = 30;
/* Format milliseconds as HH:MM:SS.mmm */
function fmtTime(ms) {
const h = Math.floor(ms / 3600000) % 24;
const m = Math.floor(ms / 60000) % 60;
const s = Math.floor(ms / 1000) % 60;
const ms2 = Math.floor(ms) % 1000;
return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' +
String(s).padStart(2,'0') + '.' + String(ms2).padStart(3,'0');
}
/* Animation loop — advances both clocks (local clock drifts at driftRate) */
function tick(now) {
if (lastTick === null) lastTick = now;
const dt = now - lastTick;
lastTick = now;
refTime += dt;
localOffset += driftRate * dt;
updateDisplays();
requestAnimationFrame(tick);
}
function localTime() { return refTime + localOffset; }
function updateDisplays() {
document.getElementById('ref-display').textContent = fmtTime(refTime);
document.getElementById('local-display').textContent = fmtTime(localTime());
const offMs = localOffset;
const sign = offMs >= 0 ? '+' : '';
document.getElementById('offset-display').textContent =
i18nFmt('offset %sign%%ms% ms', { sign: sign, ms: offMs.toFixed(1) });
drawCanvas();
}
/* Simulate one NTP round-trip and apply the computed offset correction */
function doNtpExchange() {
/* T1: client sends request */
const T1 = refTime;
/* Simulated one-way network delay with random jitter */
const networkDelay = 20 + Math.random() * 60;
const jitter = (Math.random() - 0.5) * 30;
/* T2: server receives request (server clock = reference) */
const T2 = T1 + networkDelay + jitter;
const serverLocal = T1 + networkDelay + jitter;
/* T3: server sends reply (tiny processing delay added) */
const T3 = serverLocal + 2;
const returnDelay = 20 + Math.random() * 60;
const jitter2 = (Math.random() - 0.5) * 30;
/* T4: client receives reply (measured on client clock) */
const T4client = localTime() + returnDelay + jitter2;
const T4ref = T1 + networkDelay + jitter + 2 + returnDelay + jitter2;
/* Round-trip delay: total transit time minus server processing */
const delta = (T4ref - T1) - (T3 - T2);
/* Clock offset: assumes symmetric delay */
const theta = ((T2 - T1) + (T3 - T4client)) / 2;
/* Apply offset correction and randomise drift for next interval */
localOffset += theta;
driftRate = (Math.random() - 0.5) * 0.00003;
history.push({ delta: delta.toFixed(1), theta: theta.toFixed(1) });
if (history.length > MAX_HISTORY) history.shift();
/* Color-coded feedback based on offset magnitude */
const cls = Math.abs(theta) < 5 ? 'synced' : '';
setInfo(i18nFmt('Exchanged — delay %delta% ms, offset %theta% ms applied.', { delta: delta.toFixed(1), theta: theta.toFixed(1) }), cls);
}
function setInfo(text, cls) {
const el = document.getElementById('info-text');
el.textContent = text;
el.className = 'info-text ' + (cls || '');
}
/* Draw offset history bars on canvas */
function drawCanvas() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
/* Draw zero axis */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke();
/* Zero-offset label on axis */
ctx.fillStyle = '#999';
ctx.font = '10px ui-monospace,monospace';
ctx.fillText('0', 4, H/2 - 3);
if (history.length < 2) return;
/* Draw one bar per historical exchange, color by offset size */
const barW = Math.min(16, Math.floor((W - 20) / MAX_HISTORY));
const maxVal = 100;
history.forEach((h, i) => {
const x = 20 + i * ((W - 20) / MAX_HISTORY);
const theta = parseFloat(h.theta);
const barH = Math.min(Math.abs(theta) / maxVal * (H/2 - 6), H/2 - 6);
ctx.fillStyle = Math.abs(theta) < 5 ? '#0a7d33' : Math.abs(theta) < 20 ? '#f59e0b' : '#e63946';
if (theta > 0) {
ctx.fillRect(x, H/2 - barH, barW - 2, barH);
} else {
ctx.fillRect(x, H/2, barW - 2, barH);
}
});
}
/* Toggle automatic periodic synchronization */
function toggleAuto() {
isAuto = !isAuto;
const btn = document.getElementById('btn-auto');
if (isAuto) {
btn.textContent = 'Stop auto';
btn.classList.add('active');
autoTimer = setInterval(doNtpExchange, 1200);
} else {
btn.textContent = 'Auto-sync';
btn.classList.remove('active');
clearInterval(autoTimer);
}
}
/* Reset clocks — apply random drift so demo is interesting on reload */
function reset() {
if (isAuto) toggleAuto();
refTime = Date.now() % 86400000;
localOffset = (Math.random() - 0.5) * 800;
driftRate = (Math.random() - 0.5) * 0.00004;
history.length = 0;
lastTick = null;
setInfo('Press "Sync once" to perform an NTP exchange.', '');
}
document.getElementById('btn-sync').addEventListener('click', doNtpExchange);
document.getElementById('btn-auto').addEventListener('click', toggleAuto);
document.getElementById('btn-reset').addEventListener('click', reset);
/* Initialize and start animation */
reset();
requestAnimationFrame(tick);
<!-- Main layout — two clock displays, offset history chart, info row, buttons -->
<div class="panel" id="panel">
<div class="clocks">
<div class="clock-box">
<div class="clock-label">Reference (Server)</div>
<div class="clock-display" id="ref-display">00:00:00.000</div>
<div class="clock-sub">Atomic clock — always accurate</div>
</div>
<div class="clock-box local-box">
<div class="clock-label">Local Clock</div>
<div class="clock-display" id="local-display">00:00:00.000</div>
<div class="clock-sub" id="offset-display">offset unknown</div>
</div>
</div>
<div class="timeline" id="timeline" title="Offset history per NTP exchange — green=close, yellow=moderate, red=large">
<canvas id="canvas" width="560" height="90"></canvas>
</div>
<div class="info-row">
<span id="info-text" class="info-text">Press "Sync once" to perform an NTP exchange.</span>
</div>
<div class="btns">
<button id="btn-sync" type="button">Sync once</button>
<button id="btn-auto" type="button" class="ghost">Auto-sync</button>
<button id="btn-reset" type="button" class="ghost">Reset drift</button>
</div>
</div>
/* Base layout and clock box styles */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #1d2733; background: transparent; }
.panel { padding: .8rem; }
.clocks { display: flex; gap: 1rem; margin-bottom: .7rem; }
.clock-box { flex: 1; background: #e8eef3; border: 1px solid #cdd9e3; border-radius: 10px;
padding: .6rem .8rem; text-align: center; }
.local-box { background: #fff7e6; border-color: #f0c060; }
.clock-label { font-size: .72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #556; margin-bottom: .25rem; }
.clock-display { font: 700 1.35rem ui-monospace, monospace; letter-spacing: .04em; }
.clock-sub { font-size: .72rem; color: #667; margin-top: .2rem; min-height: 1em; }
/* Timeline canvas styles */
.timeline { border: 1px solid #cdd9e3; border-radius: 8px; overflow: hidden;
background: #f5f8fb; cursor: default; margin-bottom: .5rem; }
canvas { display: block; width: 100%; }
/* Info row styles */
.info-row { min-height: 1.6em; margin-bottom: .5rem; }
.info-text { font-size: .85rem; color: #334; }
.info-text.synced { color: #0a7d33; font-weight: 600; }
.info-text.bad { color: #c92f3c; font-weight: 600; }
/* Button styles */
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 13px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #0a7d33; border-color: #0a7d33; color: #fff; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* NTP core: four timestamps T1..T4 from one round trip */
/* offset theta = ((T2-T1)+(T3-T4))/2 | delay delta = (T4-T1)-(T3-T2) */
/* Simulation state — refTime=wall clock ms, localOffset=how far local clock is off */
const MS = 1;
let refTime = 0;
let localOffset = 0;
let driftRate = 0;
let lastTick = null;
let autoTimer = null;
let isAuto = false;
const history = [];
const MAX_HISTORY = 30;
/* Format milliseconds as HH:MM:SS.mmm */
function fmtTime(ms) {
const h = Math.floor(ms / 3600000) % 24;
const m = Math.floor(ms / 60000) % 60;
const s = Math.floor(ms / 1000) % 60;
const ms2 = Math.floor(ms) % 1000;
return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0') + ':' +
String(s).padStart(2,'0') + '.' + String(ms2).padStart(3,'0');
}
/* Animation loop — advances both clocks (local clock drifts at driftRate) */
function tick(now) {
if (lastTick === null) lastTick = now;
const dt = now - lastTick;
lastTick = now;
refTime += dt;
localOffset += driftRate * dt;
updateDisplays();
requestAnimationFrame(tick);
}
function localTime() { return refTime + localOffset; }
function updateDisplays() {
document.getElementById('ref-display').textContent = fmtTime(refTime);
document.getElementById('local-display').textContent = fmtTime(localTime());
const offMs = localOffset;
const sign = offMs >= 0 ? '+' : '';
document.getElementById('offset-display').textContent =
i18nFmt('offset %sign%%ms% ms', { sign: sign, ms: offMs.toFixed(1) });
drawCanvas();
}
/* Simulate one NTP round-trip and apply the computed offset correction */
function doNtpExchange() {
/* T1: client sends request */
const T1 = refTime;
/* Simulated one-way network delay with random jitter */
const networkDelay = 20 + Math.random() * 60;
const jitter = (Math.random() - 0.5) * 30;
/* T2: server receives request (server clock = reference) */
const T2 = T1 + networkDelay + jitter;
const serverLocal = T1 + networkDelay + jitter;
/* T3: server sends reply (tiny processing delay added) */
const T3 = serverLocal + 2;
const returnDelay = 20 + Math.random() * 60;
const jitter2 = (Math.random() - 0.5) * 30;
/* T4: client receives reply (measured on client clock) */
const T4client = localTime() + returnDelay + jitter2;
const T4ref = T1 + networkDelay + jitter + 2 + returnDelay + jitter2;
/* Round-trip delay: total transit time minus server processing */
const delta = (T4ref - T1) - (T3 - T2);
/* Clock offset: assumes symmetric delay */
const theta = ((T2 - T1) + (T3 - T4client)) / 2;
/* Apply offset correction and randomise drift for next interval */
localOffset += theta;
driftRate = (Math.random() - 0.5) * 0.00003;
history.push({ delta: delta.toFixed(1), theta: theta.toFixed(1) });
if (history.length > MAX_HISTORY) history.shift();
/* Color-coded feedback based on offset magnitude */
const cls = Math.abs(theta) < 5 ? 'synced' : '';
setInfo(i18nFmt('Exchanged — delay %delta% ms, offset %theta% ms applied.', { delta: delta.toFixed(1), theta: theta.toFixed(1) }), cls);
}
function setInfo(text, cls) {
const el = document.getElementById('info-text');
el.textContent = text;
el.className = 'info-text ' + (cls || '');
}
/* Draw offset history bars on canvas */
function drawCanvas() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
/* Draw zero axis */
ctx.strokeStyle = '#cdd9e3';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, H/2); ctx.lineTo(W, H/2); ctx.stroke();
/* Zero-offset label on axis */
ctx.fillStyle = '#999';
ctx.font = '10px ui-monospace,monospace';
ctx.fillText('0', 4, H/2 - 3);
if (history.length < 2) return;
/* Draw one bar per historical exchange, color by offset size */
const barW = Math.min(16, Math.floor((W - 20) / MAX_HISTORY));
const maxVal = 100;
history.forEach((h, i) => {
const x = 20 + i * ((W - 20) / MAX_HISTORY);
const theta = parseFloat(h.theta);
const barH = Math.min(Math.abs(theta) / maxVal * (H/2 - 6), H/2 - 6);
ctx.fillStyle = Math.abs(theta) < 5 ? '#0a7d33' : Math.abs(theta) < 20 ? '#f59e0b' : '#e63946';
if (theta > 0) {
ctx.fillRect(x, H/2 - barH, barW - 2, barH);
} else {
ctx.fillRect(x, H/2, barW - 2, barH);
}
});
}
/* Toggle automatic periodic synchronization */
function toggleAuto() {
isAuto = !isAuto;
const btn = document.getElementById('btn-auto');
if (isAuto) {
btn.textContent = 'Stop auto';
btn.classList.add('active');
autoTimer = setInterval(doNtpExchange, 1200);
} else {
btn.textContent = 'Auto-sync';
btn.classList.remove('active');
clearInterval(autoTimer);
}
}
/* Reset clocks — apply random drift so demo is interesting on reload */
function reset() {
if (isAuto) toggleAuto();
refTime = Date.now() % 86400000;
localOffset = (Math.random() - 0.5) * 800;
driftRate = (Math.random() - 0.5) * 0.00004;
history.length = 0;
lastTick = null;
setInfo('Press "Sync once" to perform an NTP exchange.', '');
}
document.getElementById('btn-sync').addEventListener('click', doNtpExchange);
document.getElementById('btn-auto').addEventListener('click', toggleAuto);
document.getElementById('btn-reset').addEventListener('click', reset);
/* Initialize and start animation */
reset();
requestAnimationFrame(tick);
Notice that each correction brings the local clock closer but never perfectly on target — network jitter (random variation in delay) means each measurement is noisy. NTP solves this by averaging many samples and applying a filter that discards outliers. Press Sync once to do a single NTP exchange; press Auto-sync to keep synchronizing automatically and watch the clock converge.
Comments
Loading comments...