Fix RangeError: Invalid time zone specified in Node.js
Find which of the three causes threw the error, from the value printed after the colon, and make toLocaleString and Intl.DateTimeFormat accept the zone again.
TL;DR#
- Cause: the
timeZoneoption oftoLocaleString()andIntl.DateTimeFormatonly accepts an IANA zone name such asEurope/LondonorUTC, and, on recent runtimes, a numeric offset. Anything else throwsRangeError: Invalid time zone specified: <value>, and the value after the colon tells you which case you are in. +0000or+01:00: offsets are rejected by Node 20 and 21 and accepted by Node 22.0.0 and later. Upgrade Node, or useUTCand IANA names on the old release.UTC+1,GMT+1or a misspelt name: never valid, on any version tested. Convert it to+01:00or to an IANA name.Etc/Unknown: theTZenvironment variable is set but empty. Unset it or give it a real zone.- Reproduced on 30 October 2026 in the official Docker images for Node 20.20.2, 21.2.0, 21.7.3, 22.0.0, 22.23.4, 24.22.2 and 26.13.0.
The error#
The Stack Overflow question Nodejs RangeError: Invalid time zone specified formats the current date with timeZone: "+0000". It worked in the asker's Chrome 122 console and failed under Node.js v21.2.0. The same script in the official node:21.2.0-alpine image:
### docker run node:21.2.0-alpine node t.js
node v21.2.0 v8 11.8.172.17-node.15 icu 73.2 tz 2023c
"+0000" -> RangeError: Invalid time zone specified: +0000
"+01:00" -> RangeError: Invalid time zone specified: +01:00
"+05:30" -> RangeError: Invalid time zone specified: +05:30The test script loops over a list of candidate values and prints either the formatted date or the error message, so each line is one timeZone value. In a real application the first bad value stops the process with a stack trace whose first frame is at Date.toLocaleString (<anonymous>), as in the question, or at new DateTimeFormat (<anonymous>).
Why it happens#
Offsets are a 2023 addition to the standard#
For years the internationalisation spec (ECMA-402) only allowed IANA time zone names in timeZone. Numeric offsets came with ecma402 PR #788, "Normative: Allow UTC offset time zones", merged on 27 September 2023, three months before the question was asked. The current MDN reference for the Intl.DateTimeFormat constructor now lists offset identifiers next to IANA names. The asker's Chrome 122 already accepted offsets; Node v21.2.0 did not, which is the whole difference between the two consoles in the question.
The reproduction ran the same script in seven official images. Node 20.20.2, 21.2.0 and 21.7.3 (the current node:21-alpine tag) reject every offset. Node 22.0.0 accepts all three, and so do 22.23.4, 24.22.2 and 26.13.0:
### docker run node:22.0.0-alpine node t.js
node v22.0.0 v8 12.4.254.14-node.10 icu 74.2 tz 2024a
"+0000" -> 01/01/1970, 00:00:00
"+01:00" -> 01/01/1970, 01:00:00
"+05:30" -> 01/01/1970, 05:30:00It is not a missing ICU build#
A common first guess is a Node binary built with reduced ICU data. Official binaries have not been built that way for years: the Node 13.0.0 changelog says "Node.js releases are now built with default full-icu support." The reproduction settles it more directly. Node 21.7.3 and Node 22.0.0 report the same ICU 74.2 with the same tz 2024a data, and the first rejects +01:00 while the second accepts it; the JavaScript engine (V8 11.8 against 12.4) is what differs between the two. Node 20 also resolves Europe/London and Etc/GMT-1 without complaint while rejecting +01:00, so the zone data is there; the offset syntax is what the older engine does not accept.
Strings that were never time zones#
UTC+1 and GMT+1 look like offsets but match neither form the spec allows, and they throw on every version tested, Node 26 included:
### docker run node:26-alpine node t.js
node v26.13.0 v8 14.6.202.34-node.40 icu 78.3 tz 2026e
"UTC+1" -> RangeError: Invalid time zone specified: UTC+1
"GMT+1" -> RangeError: Invalid time zone specified: GMT+1
"Europe/Londn" -> RangeError: Invalid time zone specified: Europe/Londn
"Etc/Unknown" -> RangeError: Invalid time zone specified: Etc/UnknownA typo in an IANA name (Europe/Londn) gives the same error, which is why the message prints the value: read it before changing any code.
Fix#
1. On Node 20 or 21: upgrade, or stop passing offsets#
The Node.js release schedule ended Node 20 on 2026-04-30 and Node 21 on 2024-06-01, so the code in the question runs on an unsupported runtime. Moving to a current line (22, 24 or 26) makes timeZone: "+0000" work unchanged.
If the runtime cannot move yet, pass a name the old engine knows. For UTC, that is the value from the question's second answer:
const t = new Date().toLocaleString('en-GB', { timeZone: 'UTC' });
console.log(t);For other whole-hour offsets the IANA database has fixed Etc/GMT zones, with a trap: the IANA etcetera file explains that these names follow POSIX, which has "positive signs west of Greenwich". Etc/GMT-1 is one hour ahead of UTC, and Etc/GMT+4 is four hours behind. The file defines them only in whole hours, so a half-hour offset such as +05:30 needs a city name, Asia/Kolkata.
2. Normalise user input on Node 22 and later#
When the zone comes from a form, a config file or an API as UTC+1 or GMT-3, convert it to the offset form the spec accepts, then validate it before formatting:
function isValidTimeZone(tz) {
try {
new Intl.DateTimeFormat('en-GB', { timeZone: tz });
return true;
} catch {
return false;
}
}
function toOffsetTimeZone(input) {
const m = /^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$/i.exec(input.trim());
if (!m) return input;
const [, sign, hours, minutes = '00'] = m;
return `${sign}${hours.padStart(2, '0')}:${minutes}`;
}On Node 24 every converted value validates, and a misspelt name is still caught:
"UTC+1" -> "+01:00" valid: true
"GMT-3" -> "-03:00" valid: true
"+0000" -> "+00:00" valid: true
"+5:30" -> "+05:30" valid: true
"Europe/London" -> "Europe/London" valid: true
"Europe/Londn" -> "Europe/Londn" valid: falseHere UTC+1 means one hour ahead of UTC, the way people write it in a form. That is the opposite of the POSIX meaning of the same string in a TZ variable (see the last section). On Node 20 the same helper returns valid: false for all four converted offsets, which is the behaviour from step 1: the validation is the part that keeps working on every version.
3. For Etc/Unknown: fix the TZ variable#
Etc/Unknown is never typed by hand. It is what Intl.DateTimeFormat().resolvedOptions().timeZone returns when the process has TZ set to an empty string, and any code that reads the default zone and passes it back into timeZone then throws. With docker run -e TZ= on Node 24:
TZ="" resolved: Etc/Unknown | Date: Thu Jan 01 1970 00:00:00 GMT+0000 (GMT+00:00)
reuse resolved -> RangeError: Invalid time zone specified: Etc/UnknownThe same output appeared on Node 22.23.4 and 26.13.0, on node:24-slim (Debian) as well as on Alpine. One way to get an empty value is Docker Compose: the Compose interpolation docs state "If an environment variable is not set, Compose substitutes with an empty string." A service with TZ: ${TZ} and no TZ on the host printed this warning, then the error above:
time="2026-10-30T21:05:48Z" level=warning msg="The \"TZ\" variable is not set. Defaulting to a blank string."Give the variable a default, with the ${VAR:-default} form from the same page:
services:
app:
image: node:24-alpine
environment:
TZ: ${TZ:-Europe/London}If you write environment: as a list instead, the entry is - TZ=Europe/London; the colon form inside a list is a different Compose error, covered in Docker Compose environment "unexpected type map". Outside Compose, unset TZ before starting Node also clears the error: in the reproduction the zone then resolved to UTC.
Answer 3 on the question recommends pointing /etc/localtime at a zone with ln -sf. As a negative control, the reproduction did that on Alpine, after adding the tzdata package so the link had a target, and kept TZ empty: the result was still Etc/Unknown and the same RangeError. With TZ set to an empty string, the link made no difference. The reverse also held in the tests: with TZ unset, a dangling /etc/localtime on node:24-slim and node:24-alpine, or a garbage one on node:24-slim, resolved to UTC, not Etc/Unknown.
In application code, guard the default zone so one bad environment cannot crash every date format:
function defaultTimeZone() {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
return tz && tz !== 'Etc/Unknown' ? tz : 'UTC';
}Verify the fix#
Run the value you pass through a one-liner on the exact runtime and image you deploy, not on your laptop:
docker run --rm -e TZ=Europe/London node:24-alpine node -e "console.log(Intl.DateTimeFormat().resolvedOptions().timeZone, new Date().toLocaleString('en-GB', { timeZone: '+01:00' }))"It should print a zone name and a date, not a RangeError. In the reproduction, -e TZ=Europe/London resolved to Europe/London, and the compose file with ${TZ:-Europe/London} printed:
TZ="Europe/London" resolved: Europe/London | Date: Thu Jan 01 1970 01:00:00 GMT+0100 (Greenwich Mean Time)The +0100 at the 1970 epoch is not a bug: the IANA europe file records British Standard (1968-1971), when the UK stayed one hour ahead of GMT all year.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
The accepted answer's map drifts in summer#
The question's accepted answer builds a lookup from offset to zone name with the countries-and-timezones package, and its printed map sends +00:00 to WET and +01:00 to MET. Those names belong to zones with daylight saving time, so the offset they produce depends on the date. Formatting 1 July 2026, 12:00 UTC on Node 24:
UTC 01/07/2026, 12:00:00 UTC
WET 01/07/2026, 13:00:00 WEST
MET 01/07/2026, 14:00:00 CEST
Etc/GMT-1 01/07/2026, 13:00:00 GMT+1
+01:00 01/07/2026, 13:00:00 GMT+1The map meant "+00:00" and gets one hour ahead in summer; "+01:00" gets two. For a fixed offset, use the offset itself on Node 22 and later, or the matching Etc/GMT zone on older releases.
When TZ holds an offset, nothing throws#
The TZ variable is read differently from the timeZone option. The Node.js CLI docs say Node supports basic zone IDs in TZ, and of other forms: "It may support a few other abbreviations or aliases, but these are strongly discouraged and not guaranteed." In the reproduction on Node 24, TZ=UTC+1 did not throw. resolvedOptions().timeZone returned undefined, and dates came out one hour behind UTC, because POSIX reads the sign the other way round:
TZ="UTC+1" resolved: undefined | Date: Wed Dec 31 1969 23:00:00 GMT-0100 (GMT-01:00)A misspelt TZ=Europe/Londn behaved the same way, silently falling back to UTC. These cases do not produce the RangeError; they produce wrong times, which is harder to notice. Set TZ to an IANA name, and keep offsets for the timeZone option.
Once dates format again, a Next.js or React app can hit the next problem: the server and the browser format the same date in two different zones, and hydration fails with Minified React error #425. And if the old Node release in your container is also behind other errors, the same rule applies as for SyntaxError: Unexpected token import and tsc failing with Unexpected token '?': the message is often the runtime's age showing, and upgrading Node fixes several of them at once.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
require() of ES Module string-width: fix yarn.lock in Docker
Why yarn install in a node:18 container dies in node-gyp with ERR_REQUIRE_ESM from wide-align, how to spot the broken yarn.lock entry, and two fixes reproduced in Docker.
Fix esbuild spawnSync ETXTBSY in Docker: Node 20.3 io_uring
Why npm install of esbuild started failing with ETXTBSY on node:20.3 images in June 2023, what changed in libuv and Docker since, and the build fix that holds today.
Fix EACCES: permission denied, mkdir node_modules/.cache
Reproduced in Docker: why the cache directory cannot be created, the one-line Dockerfile move that fixes it, and why an old volume keeps the error alive after a rebuild.


