Home → Detect a user's country from their IP address in Node.js (free offline database, no API calls)

Detect a user's country from their IP address in Node.js (free offline database, no API calls)

By · Node.js & JavaScript developer
Published February 16, 2023 · Updated October 1, 2026

Showing prices in the right currency, picking a default language, or applying regional rules all start with the same question: which country is this request coming from? The usual answer is a geolocation database, a file that maps IP ranges to locations. With a local copy, a lookup is a fast in-memory operation, with no third-party API to call or rate limit to hit.

This post uses the free DB-IP Lite databases (October 2026 edition) in MMDB format, read with the maxmind npm package (v5.0.7). All code ran on node:24-slim (Node 24.21) and node:26-slim (Node 26.10), with identical results.

Getting a database

Two free databases use the same MMDB format and work with the same code:

  • DB-IP Lite: downloadable without an account, updated monthly, licensed under CC BY 4.0. That license requires a visible link, "IP Geolocation by DB-IP", on pages that show the results. There's a country edition (8.3 MB, 4.1 MB gzipped) and a city edition (127 MB, 60 MB gzipped).
  • MaxMind GeoLite2: needs a free account and a license key to download, and its license requires you to delete old copies within 30 days of a new release, so you have to automate updates.

The DB-IP file name contains the year and month, so a download script can build it from the date:

curl -sSfL "https://download.db-ip.com/free/dbip-country-lite-$(date -u +%Y-%m).mmdb.gz" \
  | gunzip > dbip-country-lite.mmdb

-f makes curl fail on an HTTP error instead of saving the error page as your database. Run it from a monthly cron job or in your Docker build.

Looking up a country

npm install maxmind
import maxmind from 'maxmind';

// Loaded once at startup, then every lookup is in memory
const countries = await maxmind.open('./dbip-country-lite.mmdb');

export function countryOf(ip) {
  // get() does not validate: "1.2.3" returns a country, so check first
  if (!maxmind.validate(ip)) return null;
  return countries.get(ip)?.country?.iso_code ?? null;
}
import { countryOf } from './country.mjs';

for (const ip of ['8.8.8.8', '1.1.1.1', '81.2.69.142', '2a00:1450:4001:80b::200e', '192.168.1.10', '127.0.0.1', 'not-an-ip']) {
  console.log(ip.padEnd(26), countryOf(ip));
}
$ node lookup.mjs
8.8.8.8                    US
1.1.1.1                    AU
81.2.69.142                GB
2a00:1450:4001:80b::200e   DE
192.168.1.10               null
127.0.0.1                  null
not-an-ip                  null

IPv4 and IPv6 use the same call. Private and loopback addresses aren't in the database, so they return null. When you test on localhost or inside a Docker network, you'll always get null, and that doesn't mean the code is broken.

Note 1.1.1.1: Cloudflare's DNS resolver is served from data centres around the world, but the database lists the country the range is registered to. A database can only say where an address is registered or usually routed, not where the person is. VPNs, mobile carriers and corporate proxies all move that answer. Use the result as a default the user can change, not as proof of location.

Validate first: get() doesn't

That validate() call matters more than it looks. get() doesn't check its input, and for some malformed strings it returns a confident answer:

"not-an-ip"            validate: false  get: null
"unknown"              validate: false  get: null
"1.2.3"                validate: false  get: "AU"
"8.8.8.8, 10.0.0.1"    validate: false  get: "US"
"999.1.1.1"            validate: false  get: null

The fourth line is a raw X-Forwarded-For header value. Passing a header to get() unparsed "works" and returns the first address's country, which makes this bug easy to miss.

What else is in the record

The country edition returns more than the ISO code: the continent, an EU-membership flag, and names in ten languages:

> countries.get('81.2.69.142')
{
  continent: { code: 'EU', geoname_id: 6255148, names: { de: 'Europa', en: 'Europe', … } },
  country: {
    geoname_id: 2635167,
    is_in_european_union: false,
    iso_code: 'GB',
    names: { de: 'Vereinigtes Königreich Großbritannien und Nordirland', en: 'United Kingdom', fr: 'Royaume-Uni', … }
  }
}

The city edition adds city, subdivisions (region or state) and location:

import maxmind from 'maxmind';
const cities = await maxmind.open('./dbip-city-lite.mmdb');
const r = cities.get('81.2.69.142');
console.log({
  city: r.city?.names.en,
  region: r.subdivisions?.[0]?.names.en,
  country: r.country?.iso_code,
  lat: r.location?.latitude,
  lon: r.location?.longitude,
});
{
  city: 'London',
  region: 'England',
  country: 'GB',
  lat: 51.5143,
  lon: -0.0912244
}

Speed and memory

maxmind.open() reads the whole file into memory, so the cost is paid once at startup. The test looked up 100,000 random IPv4 addresses:

DatabaseFile sizeopen()Extra RSSLookups per second
DB-IP Country Lite8.3 MB24–40 ms~37 MB~1,000,000
DB-IP City Lite127 MB162–184 ms~185 MB~330,000

Even the city database is fast enough to run on every request. Memory is the real cost: if you only need the country, use the country edition. Open the database once at module level, as above, and never per request.

Which IP to look up

The lookup is only as good as the IP you give it, and the right one depends on how requests reach your app:

  • Direct connection: use req.socket.remoteAddress, stripping the ::ffff: prefix Node adds to IPv4 addresses on a dual-stack socket. Ignore X-Forwarded-For, because any client can send it.
  • Behind your own reverse proxy (nginx, a load balancer): take the address that your proxy adds to X-Forwarded-For, not the first one in the list. Blocking users by IP in Node.js shows how, including Express's trust proxy setting.
  • Behind a CDN that geolocates for you: you may not need a database at all.

When the CDN already knows: CF-IPCountry

Cloudflare looks up every visitor's location at the edge. Enable the Add visitor location headers Managed Transform and each request to your origin carries a CF-IPCountry header with the two-letter code. Inside Cloudflare Workers the same data is in request.cf.country. Cloudflare uses two codes that aren't real countries: XX (unknown) and T1 (Tor). This server prefers the header when it's behind Cloudflare and falls back to the database:

import http from 'node:http';
import { countryOf } from './country.mjs';

// Set this only when the app really runs behind your own proxy/CDN.
const BEHIND_CLOUDFLARE = process.env.BEHIND_CLOUDFLARE === '1';

function clientCountry(req) {
  if (BEHIND_CLOUDFLARE) {
    // Cloudflare already looked it up. XX = unknown, T1 = Tor
    const cf = req.headers['cf-ipcountry'];
    if (cf && cf !== 'XX' && cf !== 'T1') return cf;
    return countryOf(req.headers['cf-connecting-ip'] ?? '');
  }
  // Direct connection: the socket address is the only thing you can trust
  return countryOf(req.socket.remoteAddress.replace(/^::ffff:/, ''));
}

http.createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.end(JSON.stringify({ country: clientCountry(req) }));
}).listen(3130, () => console.log('listening on 3130'));

Requests sent with curl from another container on the same Docker network:

== BEHIND_CLOUDFLARE=0
plain:                     {"country":null}
X-Forwarded-For: 8.8.8.8:  {"country":null}
CF-IPCountry: PL:          {"country":null}
CF-IPCountry: XX + CF-Connecting-IP: 81.2.69.142:  {"country":null}
== BEHIND_CLOUDFLARE=1
plain:                     {"country":null}
X-Forwarded-For: 8.8.8.8:  {"country":null}
CF-IPCountry: PL:          {"country":"PL"}
CF-IPCountry: XX + CF-Connecting-IP: 81.2.69.142:  {"country":"GB"}

In direct mode, every forged header is ignored, and the private Docker address correctly gives null. With BEHIND_CLOUDFLARE=1, the header wins, and XX falls back to a database lookup of CF-Connecting-IP. But look at what that test actually did: curl, not Cloudflare, sent CF-IPCountry: PL, and the server believed it. Trusting these headers is only safe when your origin accepts traffic exclusively from Cloudflare, through a Cloudflare Tunnel or a firewall that allows only Cloudflare's IP ranges. Otherwise anyone can pick their own country by calling your server directly.

Sources & further reading

IP Geolocation by DB-IP.

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.