Published 16 August 2026 · 5 min read

Beyond Frameworks: Building TileserveX for Ultra-Low Latency Geospatial Processing

Vector tile servers usually force a choice between the ergonomics of a dynamic framework and the raw speed of a compiled binary. Pairing Plug and Cowboy with Rust NIFs removes the trade-off — here is the architecture behind TileserveX, and what the benchmarks showed.

All insights

When you are building mission-critical geospatial infrastructure, the architecture decision usually collapses into two unappealing options.

High-level dynamic frameworks — Python, Node.js — give you developer ergonomics and a rich geospatial ecosystem. They also give you poor concurrent performance under load, high memory usage, and CPU bottlenecks driven by single-threaded process locks like Python's GIL.

Pure compiled binaries — Rust, C++ — give you execution speed and small memory footprints. They also give you complex distributed state management, a steep learning curve for ordinary web routing, and real maintenance overhead once business logic gets involved.

We built TileserveX to find out whether that trade-off is actually necessary: a vector tile engine combining Elixir (Plug and Cowboy 2) with Rust (Rustler NIFs), implementing the OGC API specifications on top.

Architecture

TileserveX deliberately bypasses a full framework like Phoenix in favor of a minimal HTTP execution pipeline, separating web concurrency, state orchestration, and native CPU computation into three layers.

Three stacked layers. Top, the HTTP and routing layer on Cowboy 2 and Plug: direct dispatch of /ogc/* and /{z}/{x}/{y}.pbf endpoints, zero-overhead header injection and binary stream piping. Middle, the concurrency and state layer on the BEAM and OTP: in-memory ETS cache with TTL and a background janitor, GenServer-backed asynchronous OGC job execution, and PostGIS plus MBTiles and SQLite connection pool management. Bottom, the execution layer of Rust NIFs via Rustler: MVT 2.1 protobuf encoding and decoding, spatial math for buffer, simplify, centroid and convex hull, and coordinate transformations between EPSG:4326 and EPSG:3857.

Bare-metal routing: Plug and Cowboy 2

Phoenix has excellent ergonomics for full-stack interactive applications. Serving high-throughput binary vector tiles is a different problem: what you want is minimal pipeline overhead.

Wiring Plug.Router directly into Cowboy 2 avoids controller pipeline stack allocations, parameter parsing overhead, and channel pub/sub setup. Requests are routed and matched directly, streaming packed Protocol Buffer payloads from the storage layer or cache straight to network sockets.

Spatial computation in Rust NIFs

Computational geometry is CPU-bound by nature — line simplification, polygon clipping (Liang-Barsky, Sutherland-Hodgman), coordinate transformations, MVT 2.1 protobuf encoding.

Rather than letting that math block the primary BEAM schedulers, those operations are delegated to Rust NIFs through Rustler, configured on dirty CPU schedulers. Two properties matter here:

  • Zero-copy serialization. Vector geometry buffers pass between BEAM memory and the compiled Rust code with effectively no serialization overhead.
  • Memory and thread safety at compile time. Rust's guarantees keep memory leaks and segmentation faults out of the Erlang VM, which is not a place you want either.

OGC API compliance

Most tile renderers do one thing. This one implements the standards a geospatial platform is actually evaluated against:

  • OGC API — Tiles (Part 1): pre-rendered or dynamic vector and raster tiles in standard pyramid schemas.
  • OGC API — Features (Parts 1–3): queryable vector datasets with spatial filtering, property constraints, and flexible CRS support.
  • OGC API — Processes (Part 1): asynchronous execution of heavy spatial jobs — buffers, centroids, convex hulls — managed through BEAM GenServers.

That breadth is unusual. Comparable open-source servers implement tiles only.

Benchmarks

All runs used Vegeta against database-backed tile endpoints over identical PostGIS tables.

Sustained load at 5,000 RPS

Under a constant-rate attack of 5,000 requests per second against PostGIS tile endpoints, TileserveX and Martin both sustained the load at a 100% success rate. Median latency was ~1.3 ms against Martin's ~1.1 ms, P95 ~3.2 ms against ~2.8 ms, and P99 ~6.1 ms against ~5.4 ms. Tail latency stayed bounded — no garbage collection spikes, no scheduler starvation.

Benchmark table comparing Martin on native Rust with TileserveX on Elixir, Plug and Rustler at a target rate of 5,000 requests per second. Both reach a 100.00% success rate. Median P50 latency is approximately 1.1 ms for Martin and 1.3 ms for TileserveX; P95 is approximately 2.8 ms against 3.2 ms; P99 is approximately 5.4 ms against 6.1 ms.

Saturation at 20,000 RPS

Pushed to 20,000 requests per second against the same PostGIS instance, the bottleneck moves. It is no longer web routing — it is database socket limits and connection pool saturation. Both engines started shedding a fraction of requests at this point, ~99.7% success for TileserveX against ~99.8% for Martin, with medians of ~2.4 ms and ~2.1 ms.

The interesting result is what did not happen. OTP process isolation, plus delegating CPU-bound work to Rustler's dirty schedulers, kept the BEAM from scheduler starvation and avoided memory spikes at maximum throughput. The two engines degrade the same way, for the same reason, at the same point.

Benchmark table comparing Martin on native Rust with TileserveX on Elixir, Plug and Rustler at a target rate of 20,000 requests per second. Martin reaches approximately 99.8% success with slight socket queue saturation; TileserveX approximately 99.7%. Median P50 latency is approximately 2.1 ms for Martin and 2.4 ms for TileserveX; P95 is approximately 8.4 ms against 9.1 ms; P99 is approximately 18.2 ms against 19.8 ms.

The wider PostGIS ecosystem at 2,000 RPS

Against an un-warmed 2,000 RPS load on the same database, TileserveX ran level with the single-binary Rust servers — ~1.0 ms median against Martin's ~0.9 ms and BBOX's ~1.1 ms — and ahead of the Go-based engines, pg_tileserv at ~1.4 ms and Tegola at ~1.8 ms. All five held a 100% success rate.

The outlier is worth naming, because it is the option a lot of teams reach for first. tiPG, on Python with FastAPI and asyncpg, completed ~82.4% of requests, with a ~45 ms median and a P99 above 250 ms. That is not a tuning gap; it is the concurrency model showing through under sustained load.

Benchmark table of six PostGIS-backed tile servers at 2,000 requests per second. TileserveX on Elixir with Plug and Rustler: 100% success, approximately 1.0 ms median, 4.5 ms P99, stable. Martin on Rust: 100%, 0.9 ms, 4.1 ms, stable. pg_tileserv on Go: 100%, 1.4 ms, 6.8 ms, stable. Tegola on Go: 100%, 1.8 ms, 8.2 ms, stable. BBOX on Rust: 100%, 1.1 ms, 5.0 ms, stable. tiPG on Python with FastAPI and asyncpg: approximately 82.4% success, 45.0 ms median, over 250 ms P99, flagged for high latency and dropped requests.

What the results mean

The hypothesis was narrow: can an Elixir microservice, stripped down to Plug and Cowboy 2 and accelerated by Rust NIFs, match the execution speed of a native Rust binary without giving up OTP fault tolerance?

On these runs, yes. Median latencies sit within a fraction of a millisecond of Martin at 5,000 RPS and converge near 2 ms at the 20,000 RPS saturation point, which suggests BEAM process orchestration overhead becomes negligible once the full MVC stack is removed. The cost of fault tolerance, supervision, and hot code reloading turns out to be close to zero when you stop paying for the framework you are not using.

That is the useful takeaway for anyone making this decision: the choice between compiled execution speed and actor-model concurrency is less binary than it looks. Elixir for connection routing and process isolation, Rust for the heavy spatial math, is a combination worth considering before you commit to either extreme.

Talk to our team

More engineering notes are on the way. Each one documents a decision we actually made, with the measurements behind it.

Talk to our team