Loading Large-Scale Point Data on Web Maps

Published October 13, 2025

Showing points on a map looks simple at first. But the problem changes quickly when the dataset grows.

At tens of thousands or hundreds of thousands of points, the map is no longer just a UI component. It becomes a data loading system, a rendering pipeline, and an interaction model all at the same time.

Every pan, zoom, filter change, and click can affect what data should be loaded and how much work the browser has to do.

Recently, I built a project comparing two common architectures for loading large-scale point data on a web map:

  • server-rendered raster tile overlay
  • viewport-based vector feature loading

The key features and architecture abstracted from the two method are rebuild side by side in this project.


Approach 1: Server-Rendered Raster Tile Overlay

The first approach is to render business points into raster tiles on the server.

The backend receives a tile coordinate such as z/x/y, finds the records that belong inside that tile, draws them into a transparent PNG, and returns the image to the browser.

The frontend then loads that image using a normal tile layer.

In OpenLayers terms, this looks like a TileLayer backed by an XYZ source.

Conceptually, it works the same way as a base map tile layer, except the tile is not a road map or satellite image. It is a transparent business overlay containing point markers.

This approach has several strengths.

The browser only needs to render images, which it is very good at. Tile loading is also a mature pattern: tiles can be cached, reused, loaded progressively, and discarded as the user moves around the map.

Instead of asking the browser to manage thousands of individual features, the server sends a visual result that is already prepared for display.

But there is an important trade-off

A PNG tile contains pixels, not feature objects.

When the user clicks a point on the map, the map cannot directly know which business record was clicked. The point exists visually, but not as an interactive feature in the frontend feature tree.

So an additional interaction index is needed, In this MVP, that index is a hit grid.

The raster tile path has two separate outputs:

GET /api/legacy/raster-tiles/:z/:x/:y.png
GET /api/legacy/hit-grid/:z/:x/:y.json

The PNG tile is used for display while the hit-grid JSON is used for interaction.

When the user clicks the map, the frontend converts the click coordinate into the corresponding tile coordinate and tile-local pixel position, requests or reuses the hit grid, and performs a radius-based hit test against nearby point records.

This is the key cost of the raster overlay architecture:

visual rendering is efficient, but object-level interaction needs a separate data structure.

There is another important consideration: filter combinations.

If the map supports filters such as record type, group, status, and zoom bucket, the server needs a way to represent those combinations as layer variants.

In this MVP, the backend builds a layer key like this:

person__engineering__active__z9

That key represents a filtered layer at a certain zoom bucket.

In a production system, similar layer variants might be backed by precomputed tiles, cached query results, materialized views, or runtime rendering.

The important idea is that the raster overlay path behaves more like a server-side visualization pipeline.

The frontend asks for map tiles. The backend decides how to draw them.


Approach 2: Viewport-Based Vector Feature Loading

The second approach is to load vector data based on the current viewport.

Instead of requesting tiles by z/x/y, the frontend sends the current bounding box, zoom level, and filters to the backend.

The backend returns the points needed for the current view.

In this MVP, the response format is GeoJSON:

GET /api/modern/points?bbox=...&zoom=...&recordType=...

The frontend parses the GeoJSON, inserts features into an OpenLayers VectorSource, and uses a Cluster source to group nearby points.

The main idea is simple:

return spatial objects for the current viewport, then let the frontend render and interact with them.

This makes interaction much more natural.

Because the frontend has real features, clicking a point can use OpenLayers feature picking directly. A popup or side panel can read the feature properties without asking for a separate hit-grid endpoint.

It also makes dynamic filtering easier.

The backend does not need to prepare every possible layer combination as a visual tile layer. It can treat the request more like a normal spatial API query:

bbox + zoom + filters -> FeatureCollection

But this approach has its own limits.

GeoJSON is easy to debug and easy to integrate, but it is verbose. The browser still has to download text, parse JSON, create map features, update the vector source, refresh clusters, and repaint the map.

If too many features are returned for a single viewport, the frontend can still become the bottleneck.

So viewport-based vector loading is not automatically faster.

It only works well when it is combined with the right data reduction strategy:

  • spatial indexes
  • zoom-based sampling
  • server-side clustering
  • feature limits
  • vector tiles
  • binary formats
  • caching

In the MVP, the vector path is intentionally simple. The backend filters by viewport and applies a deterministic zoom-based sampling strategy. It is enough to show the architecture, but it is not a production-grade spatial index.

A real system would likely replace that part with PostGIS, an R-tree, a grid index, H3, S2, or another spatial indexing strategy.


Project Overview

The project is called tile-based-overlay-mvp.

It uses a simple workspace structure:

tile-based-overlay-mvp/
  frontend/   React + Vite + OpenLayers
  backend/    Express + TypeScript + PNG tile rendering

The dataset contains 100,000 generated point records and each point has a few business-like attributes:

type PointRecord = {
  id: string;
  name: string;
  recordType: "person" | "company" | "project" | "asset";
  group: "engineering" | "design" | "sales" | "research";
  status: "active" | "inactive" | "archived";
  score: number;
  longitude: number;
  latitude: number;
};

Both rendering modes read from the same in-memory point store.


The Raster Tile Path

The raster tile path simulates a server-rendered point overlay.

The backend route receives tile coordinates and filters, builds a layer key, selects matching records, clips them to the tile bounding box, and renders the result into a 256×256 transparent PNG.

The high-level flow looks like this:

parse z/x/y
  -> parse filters and zoom
  -> build layer key
  -> get matching layer records
  -> clip records to tile bbox
  -> draw points into PNG
  -> return image/png

The response also includes performance headers:

X-Legacy-Layer-Key
X-Legacy-Tile-Key
X-Legacy-Point-Count
X-Legacy-Backend-Ms
X-Legacy-Encode-Ms

The frontend reads these headers and uses them in the performance panel.

Because the point layer is a PNG image, the frontend requests a hit grid for the clicked tile when needed.

The hit grid contains tile-local point positions and lightweight record metadata. The frontend then performs hit testing in tile pixel space.

PNG tile      -> visual display
hit-grid JSON -> point interaction

The Vector Feature Path

The vector path uses the current map viewport as the request boundary.

When the user pans or zooms, the frontend reads the current bounding box and zoom level. The request is debounced to avoid reloading too aggressively during small map movements.

The backend then returns a GeoJSON FeatureCollection for the current view.

The high-level flow looks like this:

OpenLayers moveend
  -> read viewport bbox and zoom
  -> debounce viewport changes
  -> request point data
  -> parse GeoJSON
  -> update VectorSource
  -> refresh Cluster source
  -> render points and clusters

This path is much closer to a typical data API.

The frontend receives real feature objects, so interaction is straightforward. If the clicked cluster contains a single feature, the app can show its details directly.

The backend response also includes metadata such as point count, processing time, serialization time, payload size, and strategy.

That metadata is used by the performance panel to compare the vector path against the raster tile path.


Performance Indicators

One thing I wanted from the beginning was a visible performance panel.

Without it, the demo would only show that both approaches can render points.

The interesting part is how the cost changes.

The MVP tracks metrics such as:

  • points received
  • rendered points
  • backend processing time
  • network and parse time
  • map update time
  • total load time
  • payload size
  • tile request count
  • hit-grid lookup time
  • current zoom
  • current viewport bbox
  • recent load samples

The two modes collect these metrics differently.

The vector path mostly uses the metadata from the GeoJSON API response, plus frontend timing around parsing and map source updates.

The raster path aggregates information from many tile requests, tile response headers, tile load events, and map render completion.

This difference is exactly why the panel is useful.

It shows that the two architectures do not spend time in the same places.

A raster tile overlay may reduce frontend feature creation, but it introduces multiple tile requests and a separate hit-grid lookup path.

A vector feature path may simplify interaction, but it can increase payload size, parsing cost, and map update time when too many features enter the viewport.


What the MVP Implements

The current MVP includes:

  • 100,000 generated point records
  • OpenLayers-based map UI
  • switchable base maps
  • server-rendered PNG point tiles
  • hit-grid JSON for raster tile interaction
  • viewport-based GeoJSON loading
  • vector rendering with clustering
  • filters for record type, group, and status
  • point details through map interaction
  • performance indicators for both architectures

It is an architecture experiment, not trying to become a full GIS platform.



The Trade-Off

A server-rendered raster tile overlay is still useful when the map needs to display very dense visual layers, styles are relatively stable, object-level interaction is limited, and tile caching provides real value.

A viewport-based vector loading path is more natural when the application needs rich interaction, dynamic filters, object-level styling, and close integration with frontend UI state.

If the dataset grows further and the system needs both tile-based loading and vector-level interaction, vector tiles become the next natural direction.

The way I think about it now is:

PNG raster tiles        -> distribute finished visuals
GeoJSON viewport query  -> distribute current viewport objects
Vector tiles            -> distribute tiled vector objects
Spatial index + aggregation -> make the system scalable

The format is only part of the architecture. The deeper question is how the system decides what data belongs in the current view and how much work should be done by the server, the network, and the browser.


Demo and Source Code

Live Demo URL

One note about the demo: the backend is deployed on Render Free Plan. Render free instances can sleep after about 15 minutes without traffic. If the first load takes a few extra seconds, that is usually a cold start from the hosting environment.

Comments