This article shows a step by step approach to building a small, high performance GeoIP HTTP API in Rust using ntex.
In less than 120 lines of code.
Why ntex?
- Runtime choice:
ntexseparates framework from the async runtime, letting you select implementations that best fit your latency and platform requirements. - Ecosystem:
ntexhas runtime-consistent crates such asntex-redis,ntex-grpc,ntex-mqtt, andntex-amqpso you can add integrations that match the same runtime model. - Low overhead:
ntexaims for high-throughput servers and gives control over worker count and threading.
Prerequisites
- Rust toolchain (stable). Install from https://rustup.rs if needed.
cargoavailable on PATH.- MaxMind mmdb files placed under
./mmdbavailable here
Install dependencies
Run these commands to add the dependencies used in this example. Each is chosen to fulfill a specific role:
ntex- the web framework.maxminddb- reading MaxMind DB formats.memmap2- memory map mmdb files for fast, zero-copy reads.lru- in process LRU cache for repeated lookups.anyhow- ergonomic error handling for simple apps.serde,serde_json- JSON (de)serialization for responses.num_cpus- detect worker count.
cargo add ntex
cargo add maxminddb --features mmap
cargo add memmap2
cargo add lru
cargo add anyhow
cargo add serde --features derive
cargo add serde_json
cargo add num_cpus
Cargo.toml:
[package]
name = "geo-api"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.102"
lru = "0.16.3"
maxminddb = { version = "0.27.3", features = ["mmap"] }
memmap2 = "0.9.10"
ntex = { version = "3.7.0", features = ["tokio"] }
num_cpus = "1.17.0"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
Create a basic endpoint
Let's start with a basic endpoint for our api:
use ntex::web;
#[derive(serde::Deserialize)]
struct GeoIpQuery {
ip: std::net::IpAddr,
}
#[web::get("/geoip")]
async fn geoip_handler(
query: web::types::Query<GeoIpQuery>,
) -> web::HttpResponse {
web::HttpResponse::Ok().json(&serde_json::json!({
"message":"ok"
}))
}
#[ntex::main]
async fn main() -> anyhow::Result<()> {
let srv =
web::HttpServer::new(async move || web::App::new().service(geoip_handler))
.workers(num_cpus::get());
srv.bind("0.0.0.0:8585")?.run().await?;
Ok(())
}
Test the endpoint:
cargo run
curl 'http://localhost:8585/geoip?ip=8.8.8.8' | jq
Should output:
{
"message": "ok"
}
Implement the logic
We will use a shared AppState struct to open and memory map the mmdb files once at startup (cheap, concurrent reads). To avoid repeated mmdb work and reduce contention, the example uses a sharded LRU cache.
The following implementation has been optimized for minimal line count at the expense of some readability and error handling.
use std::hash::{Hash, Hasher};
use lru::LruCache;
use maxminddb::Reader;
use memmap2::Mmap;
use ntex::web;
const SHARD_SIZE: usize = 16;
const CACHE_SIZE: usize = 1024;
#[derive(serde::Deserialize)]
struct GeoIpQuery {
ip: std::net::IpAddr,
}
#[derive(Clone, serde::Serialize)]
struct GeoIpResponse {
country: Option<String>,
city: Option<String>,
asn: Option<String>,
}
struct CacheShard(std::sync::Mutex<LruCache<std::net::IpAddr, GeoIpResponse>>);
impl CacheShard {
fn new() -> Self {
let cache_size = std::num::NonZeroUsize::new(CACHE_SIZE).unwrap();
Self(std::sync::Mutex::new(LruCache::new(cache_size)))
}
fn get(&self, ip: &std::net::IpAddr) -> Option<GeoIpResponse> {
self.0.lock().ok()?.get(ip).cloned()
}
fn set(&self, ip: std::net::IpAddr, response: GeoIpResponse) {
if let Ok(mut cache) = self.0.lock() {
cache.put(ip, response);
}
}
}
struct AppInner(Reader<Mmap>, Reader<Mmap>, Vec<CacheShard>);
#[derive(Clone)]
struct AppState(std::sync::Arc<AppInner>);
impl AppState {
fn open_mmap(path: &str) -> anyhow::Result<Reader<Mmap>> {
Ok(Reader::from_source(unsafe {
Mmap::map(&std::fs::File::open(path)?)?
})?)
}
fn new() -> anyhow::Result<Self> {
Ok(Self(std::sync::Arc::new(AppInner(
Self::open_mmap("./mmdb/GeoLite2-City.mmdb")?,
Self::open_mmap("./mmdb/GeoLite2-ASN.mmdb")?,
(0..SHARD_SIZE).map(|_| CacheShard::new()).collect(),
))))
}
fn shard(&self, ip: &std::net::IpAddr) -> &CacheShard {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
ip.hash(&mut hasher);
&self.0.2[(hasher.finish() as usize) % SHARD_SIZE]
}
fn lookup(&self, ip: &std::net::IpAddr) -> anyhow::Result<GeoIpResponse> {
let shard = self.shard(ip);
if let Some(v) = shard.get(ip) {
return Ok(v.to_owned());
}
let (country, city) = self
.0
.0
.lookup(*ip)?
.decode::<maxminddb::geoip2::City>()?
.map(|c| {
(
c.country.iso_code.map(|s| s.to_owned()),
c.city.names.english.map(|s| s.to_owned()),
)
})
.unwrap_or((None, None));
let asn = self
.0
.1
.lookup(*ip)?
.decode::<maxminddb::geoip2::Asn>()?
.and_then(|a| a.autonomous_system_organization.map(|s| s.to_owned()));
let result = GeoIpResponse { country, city, asn };
shard.set(*ip, result.to_owned());
Ok(result)
}
}
#[web::get("/geoip")]
pub async fn geoip_handler(
app_state: web::types::State<AppState>,
query: web::types::Query<GeoIpQuery>,
) -> web::HttpResponse {
match app_state.lookup(&query.ip) {
Err(_) => web::HttpResponse::InternalServerError().finish(),
Ok(geoip) => web::HttpResponse::Ok().json(&geoip),
}
}
#[ntex::main]
async fn main() -> anyhow::Result<()> {
let app_state = AppState::new()?;
let srv = web::HttpServer::new(async move || {
web::App::new()
.state(app_state.clone())
.service(geoip_handler)
})
.workers(num_cpus::get());
srv.bind("0.0.0.0:8585")?.run().await?;
Ok(())
}
What is an LRU cache and why use it?
- LRU (least-recently-used) keeps the most recently accessed items and evicts the least recently accessed when full. For GeoIP lookups many requests repeat the same IPs, so an LRU gives a high hit rate and avoids repeated, relatively expensive mmdb decodes.
Why shard the cache?
- Sharding splits the cache into multiple independent smaller caches (each with its own mutex). A request hashes the IP to pick a shard. This reduces lock contention under concurrent requests because only the shard for that IP is locked, improving throughput. Tune
SHARD_SIZEandCACHE_SIZEto trade memory for lower contention.
Why use Arc for AppState?
AppStateholds shared, read mostly resources (the mmap'd readers and the shard vector). Wrapping it inArcgives cheap, thread safe shared ownership so each worker/handler can clone a reference to the same state without copying the underlying data. This keeps startup work minimal and avoids unnecessary cloning of large resources.
Test the service
Build and run locally:
cargo run
Try the endpoint:
curl 'http://127.0.0.1:8585/geoip?ip=8.8.8.8' | jq
Should return:
{
"country": "US",
"city": null,
"asn": "Google LLC"
}
Optimize for speed
By adding these lines to your Cargo.toml you can optimize the binary for maximum speed
[profile.release]
opt-level = 3
lto = "fat"
panic = "abort"
codegen-units = 1
Benchmark the endpoint
For fun mostly I will download a random list of ips and benchmark the endpoint using vegeta
I am testing this on localhost on an Intel i9-9900K CPU. At high request rates, my CPU is mostly the bottleneck and the benchmark doesn't really reflect real world traffic.
Assuming you have a file with ip addresses on each line you can run:
cat ips.txt | shuf \
| awk '{print "GET http://127.0.0.1:8585/geoip?ip=" $1}' \
| vegeta attack -rate=50000 -duration=10s \
| vegeta report
The result I have:

Which is pretty decent, I believe. To push the fun further, let's run it at 100k RPS
cat ips.txt | shuf \
| awk '{print "GET http://127.0.0.1:8585/geoip?ip=" $1}' \
| vegeta attack -rate=100000 -duration=10s \
| vegeta report
My result:

Again, this is not a real world benchmark, but it shows the GeoIP API is robust and fast.
Conclusion
Rust has a great ecosystem of crates that lets you write fast and robust services in a few lines of code!
You can see the source code on github.
