Vector Tile Pipelines with PMTiles

Once a dataset is too large to ship as GeoJSON, the client should download only the features in view at the current zoom — that is what vector tiles deliver. This guide covers building tiles in Python-orchestrated pipelines and packaging them as PMTiles, a single-file, server-less tile archive, with MBTiles as the SQLite-based alternative when you need a running endpoint. It is the scaling layer beneath Interactive Maps with Folium and the source-of-truth behind MapLibre GL Vector Web Maps in Web Mapping & Interactive Visualization. Two focused walkthroughs sit beneath it — Generating PMTiles from GeoParquet and Serving MBTiles with Python — and both assume the tiling model described here.

Vector tile build and serve pipeline GeoParquet or GeoJSON is tiled by tippecanoe into MBTiles, converted to PMTiles, and served from a static bucket over HTTP range requests to a MapLibre client. Build once, serve statically GeoParquet source features tippecanoe tile + simplify per zoom MBTiles SQLite store PMTiles single file range reads Static bucket / CDN → MapLibre fetches only the viewport tiles
Tiles are built once with tippecanoe, packed into PMTiles, and served as a static file — no tile server in the request path.

Architecture & Data Structures

A vector tile is a compact, pre-clipped slice of geometry for one z/x/y cell, encoded in the Mapbox Vector Tile (MVT) protobuf format. Coordinates inside a tile are not lon/lat — they are integers on a local grid (the tile extent, conventionally 4096 units square), so a tile carries no CRS of its own and stays tiny. A tileset is the full pyramid across zoom levels: zoom 0 is one tile for the whole world, and each zoom z quarters its parent into 4^z tiles. That pyramid is the data structure every downstream concern — file size, simplification, attribute budgets — ultimately trades against.

Two containers hold the pyramid, and the choice between them decides your whole serving story:

The build tool is tippecanoe, a C++ binary that ingests GeoJSON, FlatGeobuf, or line-delimited GeoJSON and emits MBTiles with per-zoom simplification and feature dropping. Python's role is orchestration: it prepares and reprojects the source, invokes tippecanoe as a subprocess, converts MBTiles to PMTiles, and verifies the result. Nothing about the pipeline is a Python library rewrite of tippecanoe — you drive the binary and own everything on either side of it.

import subprocess

# tippecanoe is a native binary invoked from Python; check=True surfaces
# a non-zero exit as a CalledProcessError instead of silently continuing.
subprocess.run([
    "tippecanoe",
    "-o", "buildings.mbtiles",
    "-zg",                      # choose max zoom automatically from feature density
    "--drop-densest-as-needed", # shed features where a tile exceeds the size budget
    "-l", "buildings",          # layer name inside the tileset (what MapLibre references)
    "--force",                  # overwrite an existing output file
    "buildings.geojson",
], check=True)

The -l layer name is not cosmetic: it becomes the source-layer a MapLibre style must reference, and a mismatch there is the most common reason a correctly built tileset renders nothing. Read it back from the tileset metadata rather than trusting your memory of the build flags.

Two properties of the PMTiles layout explain why a single file can behave like a tile server, and both are worth understanding before you tune anything. The first is Hilbert ordering: tiles are not stored in z/x/y order but along a space-filling curve that keeps geographically adjacent tiles adjacent in the file. A client panning across a city therefore reads bytes that are close together, so a CDN edge that has already cached one region's byte range frequently satisfies the next request from the same cached block. Sorting by x then y would scatter a viewport's tiles across the whole archive and defeat that entirely. The second is tile deduplication: identical tile bodies are stored once and referenced by many directory entries. For any dataset with large empty areas — a coastal country's ocean tiles, a national grid over uninhabited terrain — this collapses a substantial fraction of the pyramid, which is why a PMTiles archive is often noticeably smaller than the MBTiles it came from despite carrying the same data.

The format is versioned, and the version distinction is not academic. Archives written before the v3 specification use a different directory encoding, and current readers — including the MapLibre protocol shim — expect v3. If a .pmtiles file inherited from an older project refuses to load with no useful error, check its version before debugging anything else; pmtiles convert will rewrite a v2 archive into v3 in one pass.

Environment Configuration & Dependency Resolution

Tippecanoe is a system binary, not a Python package, and there is no native Windows build — that split shapes the whole install. On macOS and Linux you install the binary once; the Python side stays lightweight because it only prepares inputs and packs the output.

# tippecanoe: system binary (no native Windows build — use WSL2 or a container)
#   macOS:  brew install tippecanoe
#   Linux:  git clone https://github.com/felt/tippecanoe && cd tippecanoe && make -j && sudo make install
#
# Python side — conda-forge keeps the GDAL/GEOS/PROJ binary stack coherent:
conda install -c conda-forge "pmtiles=3.2.*" "geopandas=0.14.*" "gdal=3.8.*" "pyogrio=0.7.*"

gdal supplies ogr2ogr, which converts a GeoPackage, Shapefile, or GeoParquet store into the GeoJSON or FlatGeobuf that tippecanoe ingests — FlatGeobuf is worth preferring for large inputs because it streams instead of loading the whole file into memory. The pmtiles Python package provides both the pmtiles convert CLI (MBTiles → PMTiles) and a reader you can use to assert on the header and tile directory in tests. pyogrio is the fast I/O engine GeoPandas uses under the hood; pin it so read_file/to_file throughput does not shift between environments.

Pin tippecanoe too. Its default zoom heuristics, the exact behaviour of -zg, and the availability of flags such as --coalesce-densest-as-needed have all changed across releases, and an unpinned CI image will silently produce a different tileset than your laptop. Record the version alongside your build flags:

import subprocess

version = subprocess.run(
    ["tippecanoe", "--version"], capture_output=True, text=True
).stderr.strip()   # tippecanoe prints its version to stderr
print(version)     # e.g. "tippecanoe v2.51.0"

Which tippecanoe you install also matters. The original Mapbox repository stopped receiving changes years ago; active development continues in the Felt fork, and several flags this pipeline relies on — shared-node handling during simplification, attribute accumulation when features are coalesced — exist only there. Installing it also puts three companion binaries on your path that most write-ups never mention: tippecanoe-decode turns a tile back into readable GeoJSON so you can see exactly what reached the client, tile-join merges and filters existing tilesets without re-tiling from source, and tippecanoe-json-tool reshapes line-delimited GeoJSON on the way in. Any debugging session that starts with "the map is empty but the build succeeded" should start with tippecanoe-decode.

On the packing side there are two independent pmtiles implementations and they are not interchangeable. The Python package is what you import — it provides the convert entry point plus Reader and Writer classes you can assert against in tests. The Go command-line tool of the same name adds operations the Python package does not have: show prints the header and metadata of a local or remote archive, verify checks structural integrity, serve runs a local HTTP endpoint with CORS already enabled for development, and extract slices a bounding box out of a remote archive using range requests, downloading only the tiles inside the box. That last command is genuinely useful in a pipeline — you can cut a city-sized archive out of a continental one hosted elsewhere without ever fetching the whole file.

Finally, know where this toolchain stops. Tippecanoe tiles your features; it is not an OpenStreetMap basemap builder. Producing a styled, multi-layer basemap from a planet or continent extract is a different job with a different tool — Planetiler, a JVM application that reads OSM PBF and writes MBTiles or PMTiles directly, does in about an hour what a naive tippecanoe pipeline over the same data would not finish. Reach for it when the deliverable is a basemap, and keep tippecanoe for the thematic layers you draw on top.

Container choice: MBTiles or PMTiles Both containers start from the same tippecanoe MBTiles build. If you need authentication, per-request filtering, or an existing tile server, keep MBTiles behind a running process. Otherwise convert one step further to PMTiles and serve it from a static bucket. tippecanoe build produces one MBTiles Need auth, per-request filtering, or an existing tile server? yes no Keep MBTiles SQLite in the request path Flask / FastAPI · tileserver-gl martin · needs a live process Convert to PMTiles pmtiles convert → S3 / R2 / CDN HTTP Range reads · no server default for public read-only maps
Both paths share one tippecanoe MBTiles output; PMTiles is a single convert step further, chosen whenever no live process is required.

Vectorized Operations & Core Workflow

The end-to-end pipeline has four stages: produce source features (ideally GeoParquet from a cloud-native workflow), tile with tippecanoe, convert to PMTiles, and upload the single file. Everything before tiling is ordinary vectorized GeoPandas work; tippecanoe is the only non-Python step, and the PMTiles conversion is pure Python. The detailed, copy-paste recipe for the GeoParquet path is in Generating PMTiles from GeoParquet.

import subprocess
import geopandas as gpd

# 0. Prepare source: metric work stays upstream, reproject to 4326 as the LAST step
parcels = gpd.read_file("parcels.gpkg")            # EPSG:25832 (UTM 32N)
parcels["area_ha"] = parcels.geometry.area / 1e4   # metric attribute computed first
parcels.to_crs(epsg=4326).to_file(                 # FlatGeobuf streams into tippecanoe
    "parcels_4326.fgb", driver="FlatGeobuf"
)

# 1. Vector features → MBTiles (tippecanoe does the tiling + per-zoom simplification)
subprocess.run([
    "tippecanoe", "-o", "parcels.mbtiles", "-zg",
    "--coalesce-densest-as-needed",   # merge, not drop, where tiles get too dense
    "-l", "parcels",
    "--force", "parcels_4326.fgb",
], check=True)

# 2. MBTiles → PMTiles (single-file, static-host ready)
subprocess.run(["pmtiles", "convert", "parcels.mbtiles", "parcels.pmtiles"], check=True)

Two flags carry most of the tuning weight. -zg ("guess") lets tippecanoe pick the maximum zoom from feature density instead of you hard-coding it — good for a first pass, but pin an explicit --maximum-zoom once you know the deepest zoom users actually reach, because every extra zoom level roughly quadruples the tile count. --coalesce-densest-as-needed and --drop-densest-as-needed are the two ways to stay under the per-tile size budget: coalesce merges adjacent features of the same layer (right for contiguous polygons like parcels or land use), drop removes whole features (right for point clouds where thinning is acceptable). Choosing the wrong one is why a map either loses features at low zoom or renders as an unreadable smear.

For point data specifically, add --cluster-distance or -r1 to keep low zooms legible, and prefer dropping over coalescing — merging point geometries is rarely meaningful.

The stage most pipelines skip is looking at what the build actually produced. Because MBTiles is a plain SQLite database, one query tells you where the bytes went, and that distribution is the fastest route to a smaller archive:

import sqlite3

con = sqlite3.connect("file:parcels.mbtiles?mode=ro", uri=True)
rows = con.execute("""
    SELECT zoom_level,
           COUNT(*)                AS tiles,
           SUM(LENGTH(tile_data))  AS bytes,
           MAX(LENGTH(tile_data))  AS largest
    FROM tiles GROUP BY zoom_level ORDER BY zoom_level
""").fetchall()

for zoom, tiles, total, largest in rows:
    print(f"z{zoom:<3} {tiles:>8,} tiles  {total/1e6:>8.1f} MB  largest {largest/1e3:>6.1f} KB")
# z12       1,984 tiles      12.4 MB  largest  164.2 KB
# z13       7,612 tiles      41.9 MB  largest  238.7 KB
# z14      29,455 tiles     186.3 MB  largest  491.0 KB

Two signals matter in that table. The deepest zoom almost always dominates the total, which is the concrete form of the rule that each extra level roughly quadruples the tile count — if z14 is 70% of the archive and your users never zoom past 13, one flag change halves the file. And a largest value pressed up against 500 KB means tippecanoe is hitting its per-tile budget and shedding data to stay under it; that is the tile where features or attributes are quietly going missing, and it is worth decoding to see what survived.

Geometry / Data Processing Details

Tippecanoe simplifies geometry per zoom level, so low zooms carry coarse outlines and high zooms carry full detail. You control that trade-off with --simplification (the Douglas-Peucker tolerance, higher = more aggressive), the density flags above, and --no-simplification-of-shared-nodes to stop adjacent polygons from developing gaps along their shared borders when simplified independently. The last flag matters for any polygon coverage — administrative boundaries, parcels, land use — where a visible sliver between two shapes is a defect.

The non-negotiable pre-step is topology validation. Tippecanoe will tile an invalid polygon without complaint — a self-intersecting ring or an unclosed shell tiles "successfully" and the artifact only surfaces as a rendering glitch on the map, often at one specific zoom. Validate and repair before tiling, using the full pipeline in Topology Validation & Repair:

import geopandas as gpd
from shapely.validation import make_valid

land_use = gpd.read_file("land_use.gpkg")            # EPSG:25832
land_use["geometry"] = land_use.geometry.apply(make_valid)
land_use = land_use[~land_use.geometry.is_empty]
land_use = land_use[land_use.geometry.is_valid]

# Simplify in the PROJECTED CRS so the tolerance is in metres, then reproject
land_use["geometry"] = land_use.geometry.simplify(5)     # 5 m — pre-thin before tiling
land_use.to_crs(epsg=4326).to_parquet("land_use_4326.parquet")

Attribute handling is a second silent trap. Tippecanoe drops attributes once a tile exceeds its size budget, and it drops them in an order you did not choose, so a field your MapLibre style depends on can vanish from dense tiles. Whitelist exactly the fields the client needs with -y, which both guarantees they survive and shrinks the tiles:

import subprocess

subprocess.run([
    "tippecanoe", "-o", "land_use.mbtiles", "-zg",
    "-y", "land_use_class",   # keep only the fields the style + tooltip use
    "-y", "area_ha",
    "-l", "land_use", "--force", "land_use_4326.parquet",
], check=True)

Coalescing raises a question the flag alone does not answer: when two adjacent parcels merge into one shape at zoom 9, what happens to their attributes? By default the surviving feature keeps one set of values and the rest are discarded, which turns a population field into a meaningless sample. --accumulate-attribute (short form -E) tells tippecanoe how to combine them instead:

import subprocess

subprocess.run([
    "tippecanoe", "-o", "census_blocks.mbtiles", "-zg",
    "--coalesce-densest-as-needed",
    "-E", "population:sum",        # merged blocks carry the summed population
    "-E", "median_income:mean",    # …and an averaged income, not a random one
    "-y", "population", "-y", "median_income",
    "-l", "census_blocks", "--force", "census_blocks_4326.geojsonl",
], check=True)

sum is right for counts and areas, mean for rates and densities, max for anything you are thresholding on. Choosing nothing is the option that silently produces a low-zoom choropleth that disagrees with the high-zoom one — a defect that survives review because both maps look plausible in isolation.

The other knob worth knowing is tile detail. -d (--full-detail) sets the coordinate precision inside each tile as a power of two; the default of 12 gives the conventional 4096-unit grid. Dropping to 10 quantises geometry to a 1024-unit grid, which shrinks tiles measurably and is invisible for area fills, but visibly staircases linework at high zoom. --low-detail applies the same idea only to zoom levels below the maximum, which is usually the better trade: coarse where nobody is looking closely, full precision where they are.

A useful mental model: do all heavy geometry work — validity repair, metric simplification, attribute pruning — in Python where you can inspect and test it, and let tippecanoe handle only the pyramid it is uniquely good at. Anything you can fix upstream is cheaper to fix upstream.

CRS Alignment & Projection Pipeline

Vector tiles are addressed in the Web Mercator tile grid, but tippecanoe expects EPSG:4326 (lon/lat) input and performs the Mercator projection itself during tiling. This is the single rule that most often breaks a pipeline: hand tippecanoe anything other than 4326 and every feature lands in the wrong place, because it interprets your projected metres or foreign degrees as lon/lat. Reproject to 4326 as the final step, after all metric processing is done, using the transformer patterns in Coordinate Systems with PyProj and the workflow in Coordinate Reference System Transformations.

import geopandas as gpd

buildings = gpd.read_file("buildings.gpkg")          # EPSG:25833 (UTM 33N)
assert buildings.crs is not None, "unknown CRS — set it before anything else"

buildings["footprint_m2"] = buildings.geometry.area  # metric attribute FIRST, in UTM
buildings_4326 = buildings.to_crs(epsg=4326)         # reproject as the LAST step

assert buildings_4326.crs.to_epsg() == 4326, "tippecanoe requires EPSG:4326"
buildings_4326.to_file("buildings_4326.fgb", driver="FlatGeobuf")

Two CRS gotchas recur here. First, a GeoDataFrame loaded with crs=None will not be reprojected by to_crs() — it raises or passes the raw numbers straight through — so always assert the CRS on load rather than assuming the file declared it. Second, do not compute areas, lengths, or buffer distances after the reprojection to 4326: degrees are not a length unit, and set_precision/simplify tolerances expressed in degrees apply inconsistently across latitudes. Keep EPSG:3857 out of the analysis entirely — it is the tile grid's internal projection, never a CRS you should measure in. Use an appropriate projected CRS (a UTM zone, a national grid) for all metric work, and treat the 4326 conversion purely as the delivery hand-off.

CRS hand-off into the tiling pipeline All metric work — area, simplification, set_precision — happens in a projected source CRS such as EPSG:25833. Reprojection to EPSG:4326 is the last step, the delivery boundary, before tippecanoe, which projects to the Web Mercator tile grid internally. Never measure in EPSG:4326 and never feed EPSG:3857 to tippecanoe. Projected source EPSG:25833 · UTM 33N area · simplify · set_precision all metric work here reproject → 4326 delivery boundary EPSG:4326 lon / lat hand-off no measurement here tippecanoe projects to Web Mercator tile grid internally (z/x/y) Never compute area or length in EPSG:4326 — degrees are not a length unit. Never hand EPSG:3857 to tippecanoe — it reads the input as lon / lat.
Do every metric operation in a projected CRS; the reproject to EPSG:4326 is the last step before tippecanoe, which handles the Web Mercator projection itself.

There is one legitimate exception to the "4326 only" rule, and knowing it stops people from working around the wrong problem. Tippecanoe accepts a -s (--projection) argument declaring the input's projection, so -s EPSG:3857 will correctly tile a source already in Web Mercator metres. It is worth using only when the data genuinely arrives that way and reprojecting it would be a wasted round trip; it is not a licence to feed the tiler a UTM zone, because 4326 and 3857 are the only two values it understands. When in doubt, reproject in Python where the transformation is explicit and testable.

A quieter consequence of the fixed tile grid is that separately built tilesets always align. Two archives produced on different days, on different machines, from different source CRSs, place z14/8623/5610 over exactly the same patch of ground, because the grid is a property of the specification rather than of the build. That is what makes layered maps and incremental rebuilds tractable: you can rebuild the parcels archive without touching the buildings archive and the two still register perfectly. It also means a misregistration on screen is never a grid problem — it is a datum problem in one of the sources, usually a national grid whose transformation to WGS 84 needed a grid shift file that PROJ could not find. The diagnosis for that lives in Fixing PyProj CRS Transformation Errors; the symptom is a consistent offset of a few metres to a few hundred metres, identical across the whole layer.

Production Export & Integration

The output format decision follows directly from the container model, and it is worth making explicitly rather than by default:

A short pre-deploy checklist catches most production surprises: verify the tileset before you upload, confirm the layer name matches the style, and check the byte size against your CDN's expectations. The pmtiles reader makes verification a unit test rather than a manual click-through:

from pmtiles.reader import Reader, MmapSource

with open("parcels.pmtiles", "rb") as f:
    reader = Reader(MmapSource(f))
    header = reader.header()
    meta = reader.metadata()

# Guardrails you can assert on in CI
assert header["max_zoom"] >= 14, "not deep enough for street-level detail"
assert meta["vector_layers"][0]["id"] == "parcels", "layer name must match the style source-layer"
print(f"zooms {header['min_zoom']}{header['max_zoom']}, "
      f"bounds {header['min_lon_e7']/1e7:.4f}..{header['max_lon_e7']/1e7:.4f}")

Set long, immutable cache headers on the PMTiles object (Cache-Control: public, max-age=31536000, immutable) and bust the cache by changing the filename on each rebuild — parcels.v3.pmtiles — rather than overwriting in place, so a client mid-session never mixes tiles from two builds.

The bucket itself needs exactly two capabilities, and a deploy fails if either is missing. It must answer Range requests with 206 Partial Content (every major object store does, but a reverse proxy or a naive Python file handler in front of it may not), and it must return CORS headers, because the page fetching the archive is almost never on the same origin as the bucket. Both are one-time configuration, and both produce failures that look nothing like their cause — a missing Range capability makes the client download the entire archive to read one tile, which presents as "the map is slow" rather than as an error.

# Minimal S3-compatible CORS policy for a public PMTiles archive
aws s3api put-bucket-cors --bucket tiles-example --cors-configuration '{
  "CORSRules": [{
    "AllowedOrigins": ["https://maps.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["range", "if-match"],
    "ExposeHeaders": ["ETag", "Content-Range", "Accept-Ranges"],
    "MaxAgeSeconds": 3600
  }]
}'

ExposeHeaders is the line people omit. Without Content-Range and Accept-Ranges exposed to script, the browser receives the partial response and hides the headers the shim needs to interpret it, producing a map that loads a first tile and then stalls.

CDN behaviour deserves a decision rather than a default. Edges differ in how they treat partial responses: some fetch and cache the full object on the first range request and serve every later range from that copy, which is ideal for an archive of a few hundred megabytes; others cache each byte range as its own entry, which is fine too but warms up more slowly; and a few do not cache partial responses at all, which turns every tile request into an origin fetch and quietly multiplies your egress bill. Test it before launch by requesting the same tile twice and reading the cache-status header, not by reasoning about the documentation.

Two client-side behaviours round out the integration picture. Overzoom means a map can zoom past the archive's maximum: MapLibre keeps rendering the deepest available tiles, scaled up, so capping --maximum-zoom at 14 does not stop users reaching zoom 18 — it only means the geometry stops gaining detail there. For most thematic layers that is an entirely acceptable trade and the single most effective way to shrink an archive. Local development does not need a bucket at all: pmtiles serve exposes a directory of archives over HTTP with range support and permissive CORS, so the page you will ship to a CDN can be developed against exactly the same protocol path.

Where does this stop scaling? Not at the archive size — multi-gigabyte PMTiles files work fine, because the client only ever reads kilobytes of them. The limits are practical ones: build time and scratch disk grow with the deepest zoom, so a planet-scale archive is a Planetiler job rather than a tippecanoe job; a viewport at high zoom issues on the order of a dozen range requests, which HTTP/2 multiplexes happily but a proxy that serialises connections will not; and any workflow needing per-user filtering has left the static model entirely and belongs behind a process.

Windows / Platform Edge Cases & Debugging

Frequently Asked Questions

PMTiles or MBTiles — how do I decide without over-thinking it? Ask whether anything has to run per request. If the answer is no — a public map, read-only data, no per-user filtering — PMTiles on a bucket is strictly simpler and cheaper, with no process to monitor, patch, or scale. If the answer is yes, the SQLite container behind an application is the honest choice, and the pattern is in Serving MBTiles with Python. Because both containers hold the identical pyramid, this decision is reversible in one pmtiles convert invocation, so it is not worth agonising over up front.

How often should tiles be rebuilt, and is there a way to update just part of the archive? Treat tiles as a derived artifact with a refresh cadence matched to the data: nightly for an operational dataset, on-merge for anything version-controlled, on demand for reference data that changes yearly. Partial updates are possible — tile-join will merge a freshly tiled region over an existing tileset — but they introduce merge ordering as a thing you have to reason about. While a full rebuild fits inside your deploy window, rebuilding everything from the analytical store is the option with fewer ways to be subtly wrong.

What actually determines the archive's size? Maximum zoom first, by a wide margin, since each level roughly quadruples the tile count. Vertex density second — an over-detailed coastline costs far more than a large number of simple shapes. Attributes third, and this one surprises people: a dozen string fields carried at every zoom can rival the geometry's contribution, which is why whitelisting fields is a size lever and not just a hygiene measure. Feature count barely appears on that list on its own.

Can I put raster tiles in a PMTiles archive too? Yes — the container is format-agnostic, and PNG, JPEG, WebP, and AVIF raster pyramids pack into it exactly as vector tiles do, with the tile type recorded in the header. That makes it a reasonable delivery format for a pre-rendered hillshade or a satellite mosaic served alongside vector layers. It does not make it a replacement for a Cloud-Optimized GeoTIFF: rasters you intend to analyse belong in the formats described in Cloud-Native Geospatial Formats, because a tile pyramid has already thrown away the values you would want to sample.

Do vector tiles preserve enough fidelity for analysis on the client? No, and designing as if they do leads to numbers that shift with zoom. Coordinates inside a tile are quantised to an integer grid, geometry is simplified per zoom level, features may be merged or dropped to fit the size budget, and attributes can be trimmed. Tiles are a rendering artifact. Anything a user needs to measure, count, or export should be answered from the analytical store — the GeoParquet or PostGIS table the tiles were built from — with the map acting as the selection interface rather than the source of truth.

Is a tile pipeline worth it below a million features? Often, yes, but not for the reason people assume. The threshold is not feature count but the payload a first-time visitor downloads and how much of it they will ever look at. A 30 MB GeoJSON of a country's parcels is unusable on a phone regardless of how many rows it has, while 300,000 sparse points may compress into a few megabytes and be perfectly fine as a single download. Measure the compressed transfer size and the vertex total; if either is uncomfortable, tile it.