A world clock that doesn't trust your clock: lessons from 64 500 city pages in plain PHP
I built WhatTimeIsIt.zone, a site that answers one question for about 64 500 places in 245 countries and territories: what time is it there right now? Each place has its own page with the time, the time zone, the next cl
I built WhatTimeIsIt.zone, a site that answers one question for about 64 500 places in 245 countries and territories: what time is it there right now? Each place has its own page with the time, the time zone, the next clock change, sunrise and sunset, the moon, the weather and the next public holiday. There are also 25+ small tools (a time zone converter, a time zone map, holidays in 247 countries, calendars and so on), in English and Bulgarian.
The stack is deliberately boring: plain PHP 8 and MariaDB on a shared hosting plan with LiteSpeed, no framework, no front-end framework, a few small vanilla JavaScript files. A git push to main deploys it.
"Show the time" sounds like a one-liner, but it turned out to be full of small traps. Here are the ones I found most interesting.
1. Your device clock is often wrong, so measure it
A clock in the browser is only as good as Date.now(), and the device clock can be off by seconds or even minutes. So the page measures it. It asks the server for the time five times and keeps the answer with the fastest round trip, because that one has the smallest error:
function sync() {
var samples = [], left = 5;
(function one() {
var t0 = performance.now();
fetch('/api/time', { cache: 'no-store' })
.then(function (r) { return r.json(); })
.then(function (j) {
var rtt = performance.now() - t0;
// The server's time when the answer arrived, minus the device time
samples.push({ rtt: rtt, skew: j.t + rtt / 2 - Date.now() });
if (--left > 0) one(); else done(samples);
});
})();
}
The skew is then added to every tick of the clock, and the page tells you the result: "Your clock is exact (Β±0.01 s)" or "Your clock is 1.3 s fast". The precision is half of the best round trip. When you come back to the tab after more than ten minutes, it measures again.
2. Your browser's time zone data can be out of date too
This one surprised me. Intl.DateTimeFormat uses the time zone database that ships with the browser's copy of ICU, and that copy can be years old. While testing I found that with ICU 74 (time zone data 2023c), Kazakhstan was an hour ahead (it moved to a single UTC+5 in 2024), and Morocco, British Columbia and Paraguay were wrong after their recent changes. A newer zone such as America/Coyhaique (2025) was simply unknown.
The same applies on the server: PHP's intl extension has its own ICU copy, separate from the IANA database that DateTimeZone uses.
So the site has one source of truth: the IANA database bundled with PHP, which I keep up to date (a weekly job reports which version the hosting runs). The server computes the offset; ICU only formats. In the browser, each page carries the offsets of the zones it shows: the current one and any change in the next three days. The clock never asks the browser what the offset is:
// Z = { "Asia/Almaty": [300], "Europe/London": [0], ... }
// A zone with a change in the next 3 days also gets [moment in ms, new offset],
// e.g. on 26 March 2027: "Europe/London": [0, [1806195600000, 60]]
function zoff(ts, tz) {
var z = Z[tz], o, i;
if (!z) return null;
for (o = z[0], i = 1; i < z.length; i++) if (ts >= z[i][0]) o = z[i][1];
return o;
}
// With a known offset, shift the moment and let Intl format it as UTC
function at(ts, tz) {
var o = zoff(ts, tz);
return o === null ? [ts, tz] : [ts + o * 6e4, 'UTC'];
}
Zone names from ICU ("Eastern European Summer Time") are shown only when ICU agrees with PHP on the offset. Otherwise the name could be wrong too.
3. PHP's sunrise was a minute or two off
Every page shows sunrise and sunset, computed on the server with no API. I started with PHP's date_sun_info() and then checked it against independent astronomy libraries. In my tests, PHP returned sunrise and sunset when the sun was about 1.1Β° below the horizon, instead of the standard 0.833Β° (atmospheric refraction plus the radius of the sun's disc). That is 1 to 2 minutes of difference, which is exactly the kind of thing people notice on a site about time. The site now refines both moments to β0.833Β°.
4. Cleaning up 64 500 place names
The places come from GeoNames. It is a great dataset, but in countries with a non-Latin script it writes names in a scholarly transcription: ThΔne, GhΔziΔbΔd, OrΓ«l, PΓ‘tra, αΈ¨amΔh. English speakers (and Wikipedia) write Thane, Ghaziabad, Oryol, Patras, Hama.
GeoNames also links many places to their English Wikipedia article, so the title of that article is a good English name. But you can't take it blindly. When I tried it on every country, I got Potenza β Potentia (the link pointed to the ancient city), Svoge β Svogue and Middletown β Middletown Township. So the rule is narrow:
- only for names with these marks, in countries whose script is not Latin (Japan is left out: Wikipedia writes KΕfu too);
- take the Wikipedia title if it is the same name once the marks are removed, or a very close spelling;
- accept a more distant spelling (OrΓ«l β Oryol) only if the GeoNames English name agrees;
- reject titles about something else: a district, an airport, a bigger city;
- otherwise just remove the marks (BΔrΔkpur β Barakpur).
The comparison folds both names first:
public static function fold(string $s): string
{
$s = \Normalizer::normalize($s, \Normalizer::FORM_D) ?: $s;
$s = mb_strtolower((string) preg_replace('/\p{Mn}+/u', '', $s)); // drop the marks
$s = (string) preg_replace("/[ββ'`Κ»ΚΌ]/u", '', $s); // and apostrophes
$s = (string) preg_replace('/[\-β.]/u', ' ', $s);
return trim((string) preg_replace('/\s+/u', ' ', $s));
}
Before writing anything, I ran the rules as a Python prototype on all places, then ported them to PHP and checked that both gave exactly the same 5 501 changes: 3 469 names from Wikipedia, 120 from GeoNames and 1 912 with the marks removed. The URLs did not change, since addresses on the site are permanent.
5. A second language with 58 000 names nobody wrote
The Bulgarian version needs every place name in Cyrillic. GeoNames has a real Bulgarian name for about 10% of the places, so the rest are transliterated: from the Ukrainian, Serbian, Greek or Russian name where it exists (never Russian for places in Ukraine), otherwise from the Latin name with rules for the language of each country (Spanish, Polish, pinyin and so on). English words in names follow Bulgarian spelling rules too, so "Village" doesn't come out letter by letter.
Compared with the 6 641 places that do have a real Bulgarian name, 63% of the automatic names match exactly and 93% differ by at most two letters. Not perfect, but much better than showing Latin letters on a Bulgarian page.
6. Small things that kept it fast
- Each page carries only its own CSS, inlined. A tool page gets the shared styles plus its own file, nothing else.
- The day and night map on the home page uses no map library. The land is a 39 KB SVG (10 KB compressed) from Natural Earth via world-atlas, and the night is computed from the sun's position in three steps: below the horizon, civil and nautical twilight.
- "Best time to call" is 24 grid cells and a line of text. The page shows the city's working day next to yours and the hours you share.
The site is also an installable PWA that keeps working offline, and a Windows app in the Microsoft Store. The Store app is a PWABuilder package that opens the live site, so changes to the site need no new package.
What I'd like to hear
If you have run into stale time zone data in a browser or on a server, I'd love to hear how you dealt with it. And if a time tool you need is missing, tell me in the comments.
I wrote this post with the help of AI, based on the project's code and my notes. I checked the facts and numbers before publishing.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.

