How I Dropped My Geospatial API Latency to 14ms Using Spring Boot and PostGIS

Jul 20, 2026


If you are running a production-grade geospatial API that handles complex spatial routing, address geocoding, and geometry intersections, you already know the stakes. In logistics, quick-commerce, and fleet management, every millisecond counts.

Recently, while auditing the performance of my geospatial API - which serves a large-scale national courier and delivery service - I noticed that under heavy client loads, certain spatial intersection endpoints were experiencing unpredictable latency spikes, ranging from 150ms up to 600ms.

For standard web apps, half a second might pass unnoticed. For an API processing thousands of concurrent delivery routes, geocoding requests, and 1km delivery buffers, it is a system bottleneck waiting to cause a thread-pool starvation.

Here is the exact step-by-step engineering journey of how I diagnosed the root cause, avoided typical cloud resource traps, and brought the average server-side response time down to a flat 14.67 ms.

The Architecture & The Architecture Trap

The API is built using Java Spring Boot, running alongside PostgreSQL with the PostGIS extension, all hosted on a single Hetzner CCX13 instance (2 Dedicated vCPUs, 8 GB RAM). In Java ecosystems, Spring Boot is often heavily criticized for its resource footprint. Out of the box, a JVM app scans annotations, loads extensive reflection metadata, and can easily hog 300MB–500MB of RAM just sitting idle.

However, looking at the real-time resource monitors on my Hetzner instance, the dedicated CPU utilization hovered safely between 10% and 20%. Memory was stable, and disk reads were at a flat 0 Bps, meaning my spatial indexes were entirely cached in RAM.

Yet, client-side testing tools like Bruno or Postman were still reporting up to 600ms total round-trip latency for specific geospatial queries. The problem wasn’t Java's startup footprint or basic CPU starving. It was a pure mathematical bottleneck inside the spatial query execution planner.

Diagnosing the Spatial Bottleneck

The system relies heavily on intersecting OpenStreetMap (OSM) road networks (MultiLineString) with complex administrative areas (MultiPolygon) like cities, municipalities, local settlements, and their corresponding pre-computed 1-kilometer buffers.

Let's look at one of the primary queries causing the latency:

SELECT street.id, alias.name, alias.tags, ST_AsEWKB(street.geom_center)
  FROM street_network street
  JOIN street_alias alias ON street.id = alias.id_street
  JOIN administrative_area smc ON smc.uuid = :smcUUID 
  AND smc.rectype = :smcRecType
  WHERE ST_Intersects(street.geom, smc.geom)
  ORDER BY alias.tags <-> :streetName 
  LIMIT 1;

When inspecting the schema of my administrative geometry table (administrative_area), I uncovered two massive underlying issues:

Issue #1: The Missing GiST Index

Running a table description (psql> \d+ administrative_area) revealed that while the table had standard B-tree primary keys, it completely lacked a GiST (Generalized Search Tree) spatial index on the geom column.

Without a GiST index, PostgreSQL cannot perform rapid bounding box filtering (&&). For every incoming REST request, the database was forced to execute a costly Sequential Scan (Full Table Scan). It was loading raw geometries into memory and running intense CPU operations against thousands of records just to find matching boundaries.

Why the Small Row Count Masked the Missing Index

Interestingly, the table itself contained only 9,599 records. In standard relational database design, a table with fewer than 10,000 rows is considered tiny. For typical text or numeric data, a Full Table Scan on a dataset this size takes less than a millisecond because PostgreSQL can easily pull the entire heap into a sequential RAM block and parse it instantly.

Because of this low row count, the database query planner often determined that skipping index lookups entirely and performing a sequential scan was the "cheapest" path. This is exactly why simply creating the GiST index over the 9,599-row table did not yield a massive, dramatic latency drop on its own.

The hidden trap here was that while the row count was small, the computational payload per row was massive. PostgreSQL was still forced to hold and pass heavy, unsimplified geometries through the execution pipeline. The small table size hid the structural architectural flaw, proving that in geospatial databases, vertex density and geometric complexity matter far more than row cardinality.

Issue #2: High Vertex Density (The Mathematical Overkill)

Administrative boundaries derived from official land registries or raw OSM data follow highly detailed natural shapes (rivers, mountain ridges, coastlines). A single municipality buffer can easily contain 10,000 to 30,000 vertices (points).

When executing ST_Intersects(street.geom, smc.geom), the underlying computational geometry algorithms (like ray-casting or edge-intersection checks) operate at a complexity of O(NlogN) or O(N2) based on vertex counts (N). Multiplying thousands of street segments by a 20,000-vertex polygon forces the CPU to run millions of floating-point operations per request.

The Solution: Two-Phase Zero-Downtime Optimization

Because this is a live production environment with zero tolerance for service interruption, changing the Spring Boot entites, rewriting repository interfaces, or triggering a new deployment pipeline was off the table. The optimization had to happen directly and safely at the database layer.

Phase 1: Non-Blocking Spatial Indexing

To fix the Full Table Scan without locking the table for active API clients, I deployed a concurrent GiST index:

CREATE INDEX CONCURRENTLY idx_administrative_area_spatial ON administrative_area USING gist(geom);
ANALYZE administrative_area;

The CONCURRENTLY flag allows PostgreSQL to construct the index in the background while streaming incoming SELECT traffic seamlessly. Once the index was live, PostGIS instantly used it to discard 99% of out-of-bounds geometries using low-cost bounding box intersections before touching raw geometry data.

Phase 2: Variable Adaptive Simplification

While the index narrowed down the candidate rows, the remaining vertices inside the intersected polygons still caused CPU drag. Since my logistics business logic does not require millimeter-level precision on 1km municipality boundaries, I introduced Topological Simplification.

Instead of introducing structural complexity via chunking algorithms like ST_Subdivide, I utilized ST_SimplifyPreserveTopology. This adaptive algorithm cleans straight paths while dynamically preserving vertex density on sharp curves, ensuring that a polygon never collapses or forms invalid self-intersections.

I backed up my high-resolution source geometries into a separate column and executed a variable simplification script based on my administrative record type levels:

-- 1. Create a safety rollback column for cities, municipalities, and settlements
ALTER TABLE administrative_area ADD COLUMN geom_original_backup geometry(MultiPolygon, 4326);
UPDATE administrative_area SET geom_original_backup = geom WHERE rectype IN ('C', 'M', 'S');
-- 2. Execute variable adaptive simplification directly to the live 'geom' column
UPDATE administrative_area SET geom = CASE 
    WHEN rectype IN ('C', 'M') THEN ST_SimplifyPreserveTopology(geom, 0.0005)   -- ~55m tolerance (City or Municipality)
    WHEN rectype = 'MB1'       THEN ST_SimplifyPreserveTopology(geom, 0.001)    -- ~110m tolerance (Municipality 1km Buffer)
    WHEN rectype = 'S'         THEN ST_SimplifyPreserveTopology(geom, 0.0001)   -- ~11m tolerance (Settlement)
    WHEN rectype = 'SB1'       THEN ST_SimplifyPreserveTopology(geom, 0.0002)   -- ~22m tolerance (Settlement 1km Buffer)
    ELSE geom
END
WHERE rectype IN ('C', 'M', 'MB1', 'S', 'SB1');
-- 3. Rebuild the index and refresh DB statistics
REINDEX INDEX idx_administrative_area_spatial;
VACUUM ANALYZE administrative_area;

The Results

By replacing raw high-density topologies with clean, simplified vectors, I stripped away 85% to 95% of unnecessary vertices across my active dataset.

To measure the real-world impact without introducing profiling overhead to my Java application, I configured a custom log format directly inside the Apache2 reverse proxy VirtualHost configuration using the %D log parameter to capture end-to-end request cycles in microseconds:

LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\" %D" combined_with_latency
CustomLog ${APACHE_LOG_DIR}/my-fantastic-api.antanaskovic.com-access.log combined_with_latency

Parsing the live production traffic post-optimization via awk yielded staggering results:

tail -n 1000 /var/log/apache2/my-fantastic-api.antanaskovic.com-access.log | awk '{sum+=$NF; count++} END {if (count > 0) print "Overall Average Latency: " (sum/count)/1000 " ms"}'

Output:

Overall Average Latency: 14.671 ms

The average end-to-end server response time dropped to an elite 14.67 ms.

The Execution Optimization vs. Network Traps

In many geospatial setups, developers blame network payloads or heavy JSON serialization for client-side latency. However, in this specific architecture, the API payload was already optimal - returning a lightweight string (such as a route ID or zone identifier) rather than raw geometry data.

This meant the 600ms spikes seen in client tools like Bruno or Postman were caused 100% by CPU-bound database geometry operations. By running ST_SimplifyPreserveTopology, I've proved that the latency drop from 600ms to sub-100ms on the client side was achieved purely by stripping away millions of unnecessary floating-point operations on the server, rather than just optimizing network packets.

Key Takeaways

  1. Don't always blame your runtime: Java Spring Boot is incredibly fast when backed by optimized data layouts. Don't waste time refactoring code when your bottleneck is mathematical.
  2. Vertices kill spatial performance, not row counts: A table with under 10,000 rows can easily cripple a dedicated CPU if individual geometries hold tens of thousands of coordinate points.
  3. Simplify early, simplify often: If your business logic operates on a macro level (like delivery zones or neighborhood buffers), raw high-fidelity boundaries are nothing but technical debt in your RAM.

Pro-tip:

If you want to run a quick benchmark on your own database to see if high vertex counts are secretly hurting your system, run this diagnostic query to audit your geometry density:

SELECT rectype, COUNT(*), AVG(ST_NPoints(geom)) AS avg_vertices
    FROM administrative_area 
    GROUP BY rectype;

If you see average vertex counts spiking into the thousands, it's time to fire up ST_SimplifyPreserveTopology.


Hopefully, this post helps you optimize your GIS layers. If you want to talk more about running high-performance load testing (stress testing) on optimized PostGIS instances to see how many thousands of concurrent requests a basic VPS can handle, feel free to reach out.

About the author

Dejan Antanasković is a software developer with over two decades of experience in designing, developing, and maintaining robust backend and frontend systems – from scientific and geospatial applications to complex web and mobile platforms.