Scaling Vector Tile Services for Peak Map Traffic
Map traffic does not scale like web traffic. A news story about a wildfire, a storm warning, an election night results map — each produces a step change in load concentrated on a small geographic extent and a narrow band of zoom levels, which is the worst possible shape for a tile platform. The requests that arrive are exactly the ones the cache does not hold, because nobody was looking at that area an hour ago, and they resolve to the most expensive queries in the system, because dense urban geometry at zoom 14 is where the cost lives. Scaling for this is a different exercise from scaling for growth. This guide extends Vector Tile Service Provisioning within Geospatial Resource Provisioning.
Where the ceiling actually is
Adding renderer replicas is the instinctive response and is usually not the constraint. Four ceilings exist and they bind in a predictable order.
Cache hit ratio. The dominant term. At a 95 per cent hit ratio the origin sees five per cent of requests; at 80 per cent it sees four times as many for identical user traffic. A traffic event pushes the ratio down precisely because the requested tiles are novel, so origin load rises far faster than user load. Improving cache behaviour is worth more than any amount of compute.
Database connections. The hard ceiling. Pool size multiplied by replica count is bounded by max_connections, so beyond a certain replica count adding renderers does nothing except bring the database closer to refusing connections.
Query cost at high zoom. Dense geometry at zoom 13 to 16 costs an order of magnitude more per tile than sparse geometry at zoom 8, and a viewport over a city is entirely composed of the expensive kind.
Renderer CPU. Usually the last to bind, and the one everyone scales first.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A tile service already provisioned per Provisioning pg_tileserv on ECS Fargate. A CDN in front of the origin. Metrics broken down by zoom band and cache result, because none of the reasoning below is possible against aggregate numbers — this is the concrete payoff of the dimension discipline in Observability for Spatial Infrastructure.
Know your current numbers before changing anything: cache hit ratio, origin requests per second at peak, p99 origin latency by zoom band, and peak pool utilisation. Scaling decisions made without these are guesses that happen to be expensive.
Step-by-step scaling
-
Normalise the cache key first. This is the cheapest large win. A tile URL carrying a client-generated cache-buster, a session parameter or an inconsistent parameter order produces distinct cache entries for identical tiles, and a platform can be running at a 60 per cent hit ratio purely because three clients spell the same request differently. Strip every query parameter that does not change the bytes.
-
Raise the cache time-to-live to match how the data actually changes. Tiles from a dataset that updates nightly do not need a five-minute TTL. Use a long TTL with a versioned path or a purge on publish, so freshness comes from invalidation rather than from expiry. This is the difference between a cache that absorbs an event and one that re-fetches everything every few minutes during it.
-
Serve stale while revalidating. During an origin slowdown, serving a slightly old tile is nearly always better than serving an error, and for a basemap it is invisible. This single directive converts an origin incident into a latency footnote for most users.
-
Pre-warm the extent when you can predict it. Some events are predictable — a scheduled data publication, an election, an announced release. Requesting the tiles for the expected extent and zoom band before the traffic arrives converts the worst case into a warm cache.
-
Scale renderers on concurrency with a cooldown longer than cache fill. Scaling on raw request count against a cache-mediated origin produces oscillation: the fleet scales up, the cache warms, load drops, the fleet scales down, and the next wave finds it small again. A cooldown longer than the fill time damps it.
-
Shed load deliberately at the edge rather than failing everywhere. A rate limit per client at the CDN keeps a scraper or a runaway client from consuming the capacity that interactive users need. Failing a few abusive callers is a better outcome than degrading for everyone.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.60" }
}
}
resource "aws_cloudfront_cache_policy" "tiles" {
name = "vector-tiles"
default_ttl = 86400
max_ttl = 604800
min_ttl = 3600
parameters_in_cache_key_and_forwarded_to_origin {
# Strip everything that does not change the bytes. A client-generated
# cache-buster or an inconsistent parameter order otherwise creates a
# distinct cache entry for an identical tile.
query_strings_config {
query_string_behavior = "none"
}
headers_config {
header_behavior = "none"
}
cookies_config {
cookie_behavior = "none"
}
enable_accept_encoding_gzip = true
enable_accept_encoding_brotli = true
}
}
resource "aws_cloudfront_response_headers_policy" "tiles" {
name = "vector-tiles-headers"
custom_headers_config {
items {
header = "Cache-Control"
# Serving a slightly old tile beats serving an error, and for a basemap
# the difference is invisible to the user.
value = "public, max-age=86400, stale-while-revalidate=604800, stale-if-error=604800"
override = true
}
}
}
# Scale on concurrency, with a cooldown longer than the cache fill time so the
# fleet does not oscillate as the cache warms and load falls away.
resource "aws_appautoscaling_policy" "tileserv_concurrency" {
name = "tileserv-concurrency"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.tileserv.resource_id
scalable_dimension = aws_appautoscaling_target.tileserv.scalable_dimension
service_namespace = aws_appautoscaling_target.tileserv.service_namespace
target_tracking_scaling_policy_configuration {
target_value = 60.0
customized_metric_specification {
metrics {
id = "utilisation"
label = "pool utilisation percent"
return_data = true
expression = "100 * active / capacity"
}
metrics {
id = "active"
metric_stat {
metric {
namespace = "SpatialPlatform/Tiles"
metric_name = "ActiveConnections"
}
stat = "Average"
}
return_data = false
}
metrics {
id = "capacity"
metric_stat {
metric {
namespace = "SpatialPlatform/Tiles"
metric_name = "PoolCapacity"
}
stat = "Average"
}
return_data = false
}
}
scale_in_cooldown = 600 # slow to shrink: the next wave finds the fleet ready
scale_out_cooldown = 120
}
}
Verification
Measure rather than assume. After normalising the cache key, the hit ratio should move within an hour and the change should be visible as a drop in origin requests per second at constant user traffic — if it does not move, something is still varying the key, and the CDN’s cache statistics by URL will show which.
Then run a load test with a realistic shape. A test that requests random tile coordinates across the whole world tests nothing useful, because that traffic caches beautifully and costs almost nothing. Replay a real access log, or synthesise a viewport-shaped pattern over dense geometry at zoom 13 to 16, and watch pool utilisation and p99 by zoom band. Confirm the autoscaling policy reacts and, importantly, that it settles — a fleet that oscillates in the test will oscillate under an event.
Finally test the failure path: block the origin and confirm the CDN continues serving stale tiles rather than errors. stale-if-error untested is stale-if-error unproven.
Preventing recurrence
- Alarm on cache hit ratio, not only on origin latency. A falling ratio precedes an origin problem by minutes, and it is the earliest actionable signal a tile platform has.
- Keep the URL contract stable and parameter-free. Every parameter a client can vary is a cache fragmentation risk; document the canonical tile URL and treat additions as breaking changes.
- Rehearse the predictable events. For a scheduled publication or an announced launch, pre-warming should be a pipeline step, not a heroic act.
- Review the zoom-band cost distribution quarterly. As data densifies, the expensive band moves, and a generalisation strategy tuned two years ago may be leaving full-resolution geometry in a band that is now hot.
Frequently Asked Questions
Should I scale renderers or the database first?
Neither, first. Fix the cache. Only once the hit ratio is high and stable does the origin’s capacity become the limiting factor, and at that point the answer is usually read replicas rather than renderers, because the pool ceiling binds before renderer CPU does.
Why does my fleet oscillate during a traffic spike?
Because it is scaling on a signal the scaling itself removes: more capacity warms the cache, load falls, the fleet shrinks, and the next wave finds it small. Lengthen the scale-in cooldown well past the cache fill time and scale on a smoothed concurrency signal rather than raw request count.
Is pre-generating tiles a better answer than scaling?
For any layer stable enough to pre-generate, yes and by a wide margin — a precomputed archive served from object storage removes the database from the request path entirely. The trade-off between live and precomputed is set out in Vector Tile Service Provisioning, and most platforms should be running both.
How do I stop one client consuming the whole platform?
Rate-limit per client at the edge. It is uncomfortable to fail requests deliberately, but the alternative during an event is that a single misbehaving client degrades the service for every interactive user, which is strictly worse.
Related
- Vector Tile Service Provisioning — the parent topic, including the live-versus-precomputed decision
- Provisioning pg_tileserv on ECS Fargate — the service this guide scales
- Observability for Spatial Infrastructure — the per-zoom metrics every decision here depends on
- Modeling Egress Costs for Raster Tile Workloads — what a traffic event does to the bill