Modeling Egress Costs for Raster Tile Workloads
Data egress is the line item that quietly dominates the bill for any high-volume raster tile service, and it is the term most estimators get wrong because tile request volume grows geometrically with zoom depth rather than linearly with users. This guide shows how to forecast and cap egress cost for a tile-serving estate from first principles — pricing the tile pyramid, separating CloudFront edge egress from direct S3 egress, and factoring in cache hit ratio — as a companion to Cost Estimation Frameworks within the wider Spatial IaC Architecture & Fundamentals practice, where a cost model has to price the real serving profile rather than a flat monthly guess.
Symptom identification and triage
An under-modeled egress line does not announce itself in the plan; it surfaces weeks later as a bill that outruns the estimate. Before rebuilding the model, classify which of three distinct failure shapes you are looking at, because each points at a different missing input:
- Actual monthly spend far exceeds the estimate, and the gap is concentrated in one service. Group your Cost and Usage Report by service and
UsageType. If the overrun sits inDataTransfer-Out-Bytes(direct S3) or<region>-DataTransfer-Out-Bytes(CloudFront), the model priced storage and compute correctly but treated egress as a constant. This is the most common signature for a new tile layer that just warmed to deep zoom. - Cost scales with a new basemap or zoom level, not with user count. A product decision to extend maximum zoom from 16 to 19 multiplies the addressable tile set by roughly 64x. If spend jumped after a zoom-depth change rather than a traffic change, the model is missing pyramid depth as a priced variable.
- CloudFront is deployed but egress cost barely dropped. The CDN is serving a low cache hit ratio — cold pyramids, per-tenant cache fragmentation, or a
Cache-Controlheader that forces revalidation — so nearly every viewer request still triggers an origin fetch and the estate pays edge egress plus S3 GET request charges with none of the caching benefit.
The triage move is to attribute the spend to a layer of the delivery path before touching the model. The diagram below traces one tile request through that path and marks where each billed term is incurred.
The tile-pyramid egress model
The number of tiles in a full quadtree pyramid from zoom 0 through maximum zoom Z is a geometric series, and it is the reason egress is non-linear:
$$N_{\text{pyramid}} = \sum_{z=0}^{Z} 4^{z} = \frac{4^{,Z+1}-1}{3}$$
The pyramid size sets the addressable surface, but you almost never serve every tile. What you pay for is the served request volume $R$, which you read from access logs, not from the pyramid count. Split $R$ into per-zoom request bands $R_z$ so the model reflects the real access distribution, because deep-zoom tiles are individually rare but collectively dominate byte volume once a region goes viral:
$$R = \sum_{z=0}^{Z} R_{z}, \qquad V_{\text{out}} = \sum_{z=0}^{Z} R_{z},\overline{s}_{z}$$
Here $\overline{s}_z$ is the average encoded tile size at zoom $z$ (dense urban tiles at zoom 18 are far heavier than sparse ocean tiles). The viewer-facing egress cost is the served byte volume times the CDN’s per-GB rate, and it is billed on every served byte regardless of cache state:
$$C_{\text{edge}} = V_{\text{out}} \times r_{\text{edge}}$$
Cache hit ratio $H$ only changes what the origin costs. A fraction $(1-H)$ of requests miss the edge and hit S3, each incurring a GET request charge $r_{\text{get}}$; the S3-to-CloudFront transfer itself is free:
$$C_{\text{origin}} = R,(1-H),r_{\text{get}}$$
Compare that with serving the same pyramid directly from S3 with no CDN, where the full byte volume is billed at the higher S3 internet-egress rate $r_{\text{s3}}$ and every request is a billable GET:
$$C_{\text{direct}} = V_{\text{out}},r_{\text{s3}} + R,r_{\text{get}}$$
Because $r_{\text{edge}} < r_{\text{s3}}$ and the origin transfer is free, the CDN wins on egress rate even at $H = 0$; the cache hit ratio is what collapses the GET-request term. Modeling all three quantities — $V_{\text{out}}$, $H$, and the two rate cards — is what turns a wild guess into a defensible forecast.
Prerequisites and environment assumptions
This guide assumes a tile pyramid stored in S3 and fronted by CloudFront, with access logs available for either layer. To reproduce the forecast and provision the guardrails you will need:
- Terraform
>= 1.10with theawsprovider pinned to~> 5.60, so a provider upgrade cannot silently change a default that shifts the rate card. Version drift in the estimator is the same hazard called out across the Cost Estimation Frameworks discipline. - CloudFront standard access logs or S3 server access logs delivered to a log bucket, plus read access to the AWS Cost Explorer API so realized egress can be reconciled against the model.
- Python 3.11+ with
pandasfor log aggregation. The forecasting script needs read access to the log bucket only. - IAM permissions for the operator provisioning guardrails:
budgets:*for the estate budget,ce:GetAnomaliesfor anomaly detection, andcloudfront:GetDistributionConfigto confirm cache behavior. The consuming ACM/TLS setup for the edge is provisioned separately per Automating ACM Certificates for Tile CDN Endpoints.
Step-by-step: forecast egress and cap it
Build the forecast from real access data first, then encode the ceiling in the same Terraform that provisions the distribution. Guessing at $V_{\text{out}}$ before reading logs is how estimates end up an order of magnitude low.
-
Derive per-zoom request bands from access logs. Parse the tile path in each log line into its
{z}/{x}/{y}components and aggregate request count and byte volume per zoom. This gives you $R_z$ and the empirical $\overline{s}_z$ directly, instead of assuming a uniform tile size across the pyramid.# forecast_egress.py — aggregate CloudFront access logs into per-zoom bands # pandas==2.2.2 import re import pandas as pd TILE_RE = re.compile(r"/tiles/(\d+)/\d+/\d+\.(?:png|webp|pbf)") def zoom_of(uri: str) -> int | None: m = TILE_RE.search(uri) return int(m.group(1)) if m else None # CloudFront log columns: cs-uri-stem is the request path, sc-bytes the bytes served. logs = pd.read_csv("cf-access.tsv", sep="\t", comment="#", usecols=["cs-uri-stem", "sc-bytes"]) logs["zoom"] = logs["cs-uri-stem"].map(zoom_of) bands = (logs.dropna(subset=["zoom"]) .groupby("zoom") .agg(requests=("sc-bytes", "size"), bytes_out=("sc-bytes", "sum")) .assign(avg_tile_kb=lambda d: d.bytes_out / d.requests / 1024)) print(bands) -
Price the forecast against both delivery paths. With $V_{\text{out}} = \sum R_z \overline{s}_z$ from step 1, apply the CloudFront and direct-S3 rate cards and the measured cache hit ratio so the decision to keep or add a CDN is quantified, not assumed. Deep-zoom bands are where the two paths diverge most.
-
Provision a scoped budget and cost-anomaly monitor in Terraform. Encode the monthly egress ceiling next to the distribution it governs, and add an anomaly monitor so a cache-hit collapse pages you within a day rather than at month end. Pin the provider and scope the budget filter to the transfer-out usage types so the alert tracks egress specifically, not blended spend.
terraform { required_version = ">= 1.10" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.60" # pin: default usage-type mappings must not drift } } } # Egress-scoped budget: alerts before the transfer-out ceiling is breached. resource "aws_budgets_budget" "tile_egress" { name = "tile-egress-${var.environment}" budget_type = "COST" limit_amount = var.monthly_egress_ceiling_usd # e.g. "1200" limit_unit = "USD" time_unit = "MONTHLY" # Track only data-transfer-out so a cache regression is visible in isolation. cost_filter { name = "UsageTypeGroup" values = ["CloudFront: Data Transfer Out", "S3: Data Transfer Out"] } notification { comparison_operator = "GREATER_THAN" threshold = 75 # forecasted-spend alert at 75% threshold_type = "PERCENTAGE" notification_type = "FORECASTED" subscriber_email_addresses = [var.finops_alert_email] } } # Cost-anomaly monitor scoped to the tile-serving cost tags. resource "aws_ce_anomaly_monitor" "tile_egress" { name = "tile-egress-anomaly-${var.environment}" monitor_type = "CUSTOM" monitor_dimension = null monitor_specification = jsonencode({ Tags = { Key = "WorkloadType", Values = ["raster-tile-serving"] } }) } -
Raise the cache hit ratio to shrink the origin term. A long
max-ageon immutable tile objects and a single shared cache key (strip cookies and irrelevant query strings from the cache policy) drive $H$ toward 1, which — per the model — eliminates the GET-request cost without changing the edge egress you owe on served bytes. Pair this with lifecycle transitions so cold pyramids age into cheaper storage, per Configuring S3 Lifecycle Rules for GIS Tiles.
Minimal reproducible forecast
The snippet below turns the per-zoom bands into a side-by-side monthly cost for both delivery paths, so the forecast is reproducible from any log sample:
# price_paths.py — CloudFront vs direct S3 monthly egress from measured bands
R = {14: 42_000_000, 16: 9_500_000, 18: 1_800_000} # requests/zoom (from logs)
S = {14: 18, 16: 34, 18: 61} # avg tile size KB/zoom
H = 0.92 # measured cache hit ratio
R_EDGE, R_S3, R_GET = 0.085, 0.09, 0.0004 / 1000 # USD per GB, GB, per GET
v_out_gb = sum(R[z] * S[z] * 1024 for z in R) / 1024**3
requests = sum(R.values())
c_edge = v_out_gb * R_EDGE + requests * (1 - H) * R_GET
c_direct = v_out_gb * R_S3 + requests * R_GET
print(f"V_out={v_out_gb:,.0f} GB CloudFront=${c_edge:,.0f} direct S3=${c_direct:,.0f}")
Verification
Confirm the model tracks reality before trusting the budget:
-
Reconcile against Cost Explorer. Query realized
DataTransfer-Outfor the tile-serving tag over the last full month and compare it with the forecast fromprice_paths.py. A gap under ~10% means the model is calibrated; a larger gap points at a stale $\overline{s}_z$ or an unmodeled zoom band.aws ce get-cost-and-usage \ --time-period Start=2026-06-01,End=2026-07-01 \ --granularity MONTHLY --metrics UnblendedCost \ --filter '{"Tags":{"Key":"WorkloadType","Values":["raster-tile-serving"]}}' \ --group-by Type=DIMENSION,Key=USAGE_TYPE \ --query "ResultsByTime[0].Groups[?contains(Keys[0],'DataTransfer-Out')]" -
Confirm the measured cache hit ratio. Pull CloudFront’s
CacheHitRatemetric from CloudWatch and check it matches the $H$ used in the forecast; a lower live value explains an origin-cost overrun. -
Fire the anomaly monitor with a canary. Temporarily lower the budget ceiling in a non-production copy and confirm the forecasted-spend notification and the anomaly monitor both fire, so the alert path is proven, not assumed.
Preventing recurrence
Encode the model so egress cannot silently run away again:
- Treat maximum zoom as a priced input in review. Any pull request that raises
maxzoommust update the egress forecast; a policy-as-code rule can flag amaxzoomchange that ships without a corresponding budget update. - Alarm on cache hit ratio, not just cost. A CloudWatch alarm on
CacheHitRatedropping below your modeled $H$ catches a caching regression days before it shows up as an egress bill. - Keep the rate card and region pinned. An unpinned region falls back to default pricing and mis-forecasts egress; pin the region in CI so the forecast uses the deployment’s real rate card.
- Age cold pyramids out. Pair the distribution with lifecycle transitions and short-TTL invalidation discipline so warm-cache assumptions hold and storage does not accumulate under the egress line.
Frequently asked questions
Does a higher cache hit ratio lower my egress bill?
Only the origin portion. Viewer-facing edge egress is billed on every served byte no matter where it is served from, so raising the cache hit ratio $H$ removes the per-miss S3 GET charge and origin work but leaves $C_{\text{edge}} = V_{\text{out}} \times r_{\text{edge}}$ unchanged. To cut edge egress you must reduce served bytes — smaller tile encodings, WebP over PNG, or fewer deep-zoom requests.
Why did my egress cost explode after raising maximum zoom by three levels?
Because the addressable pyramid grows as $4^{Z}$. Extending maximum zoom from 16 to 19 multiplies the tile surface by roughly 64x, and once clients pan across a newly available deep-zoom region the served byte volume $V_{\text{out}}$ climbs with it. Model pyramid depth as a variable, not a constant, and re-price the forecast on every maxzoom change.
Should I serve tiles directly from S3 or always put CloudFront in front?
For anything beyond a low-traffic internal viewer, CloudFront wins on egress. The edge per-GB rate is lower than S3 internet egress, the S3-to-CloudFront origin transfer is free, and caching collapses the GET-request term. Direct S3 only makes sense when request volume is tiny and a CDN’s fixed request pricing would dominate.
How do I forecast egress before I have any production traffic?
Bootstrap the request distribution from a comparable layer’s access logs or from an expected-usage assumption, weight it toward the zoom levels your default viewport lands on, and price it with price_paths.py. Then replace the assumed bands with measured ones as soon as real logs exist, and reconcile monthly against Cost Explorer.
Related
- Cost Estimation Frameworks — the parent discipline where egress is one priced tier among storage, compute, and database.
- Cost Tracking Spatial Infrastructure with Infracost — feeding modeled tile egress into the estimator as a usage input.
- Configuring S3 Lifecycle Rules for GIS Tiles — aging cold pyramids out from under the egress and storage lines.
- Automating ACM Certificates for Tile CDN Endpoints — provisioning the TLS edge the CDN egress path depends on.
- Spatial IaC Architecture & Fundamentals — the practice this egress model plugs into.