S3 Request Cost Modelling for Tiled Reads
For geospatial workloads that read many small objects — tile archives, Zarr chunks, over-partitioned Parquet — GET request charges routinely exceed egress, and the crossover sits at roughly 200 KB per object. Most cost models only account for storage and transfer, so the request term arrives as a surprise on a bill that was supposed to be dominated by bytes. This page adds the missing term to the model in cloud cost and storage lifecycle.
Quick Reference
| Access pattern | Typical object size | Dominant cost | Primary Use Case |
|---|---|---|---|
| Vector tile archive, no CDN | 5–60 KB per tile | Requests, by 10× or more | Map delivery to browsers |
| Zarr chunks, 1 MB | ~1 MB | Roughly balanced | Time-series raster analytics |
| GeoParquet, 128 MB row groups | 8–40 MB per range read | Egress, heavily | Analytical scans |
| COG window reads | 100–800 KB | Roughly balanced | Raster tile services |
| Over-partitioned Parquet | 200 KB–2 MB per file | Requests, plus listing cost | The layout to fix, not model |
The Arithmetic
Two terms, both linear, with one crossover between them. Using representative public-cloud list prices — $0.0004 per thousand GET requests and $0.09 per gigabyte of egress — one GET costs $0.0000004 and one megabyte of transfer costs $0.000088. Setting them equal gives a crossover object size of about 4.5 KB… for egress alone. But that is not the comparison that matters, because a request also carries its payload. The right question is: for a fixed volume of useful data, does splitting it into more objects cost more in requests than it saves in avoided over-fetch?
For a read pattern that fetches N objects of size S to satisfy one user action, the cost is N × request_price + N × S × egress_price. The request term dominates when request_price > S × egress_price, that is when S < request_price / egress_price — with the numbers above, about 4.5 KB. Real workloads sit above that, but not by much once you account for the fact that a viewport issues many requests: a map that renders 14 tiles of 40 KB pays 14 requests and 560 KB, and at a million map loads a month that is 14 million requests against 560 GB — $5.60 of requests against $50 of egress. Add a second layer, deeper zooms, and retina density, and the ratio moves quickly.
The figure that actually decides the bill is requests per user action, and it is set by layout, not by pricing.
Modelling It
# Requires: Python 3.10+ standard library only
from __future__ import annotations
from dataclasses import dataclass
# Representative public-cloud list prices; override per provider and region.
GET_PER_THOUSAND = 0.0004
EGRESS_PER_GB = 0.09
STORAGE_PER_GB_MONTH = 0.023
@dataclass(frozen=True)
class ReadPattern:
"""One user-visible action, described in objects rather than bytes."""
name: str
objects_per_action: int
bytes_per_object: int
actions_per_month: int
cache_hit_rate: float = 0.0
def __post_init__(self) -> None:
if not 0.0 <= self.cache_hit_rate < 1.0:
raise ValueError("cache_hit_rate must be in [0, 1)")
if self.objects_per_object_check() <= 0:
raise ValueError("objects_per_action and bytes_per_object must be positive")
def objects_per_object_check(self) -> int:
return self.objects_per_action * self.bytes_per_object
@dataclass(frozen=True)
class CostBreakdown:
requests: float
egress: float
storage: float
@property
def total(self) -> float:
return self.requests + self.egress + self.storage
@property
def request_share(self) -> float:
return self.requests / self.total if self.total else 0.0
def monthly_cost(pattern: ReadPattern, stored_gb: float) -> CostBreakdown:
"""Cost of one read pattern, with the request term made explicit."""
miss = 1.0 - pattern.cache_hit_rate
origin_requests = pattern.objects_per_action * pattern.actions_per_month * miss
origin_bytes = origin_requests * pattern.bytes_per_object
return CostBreakdown(
requests=(origin_requests / 1000.0) * GET_PER_THOUSAND,
egress=(origin_bytes / 1e9) * EGRESS_PER_GB,
storage=stored_gb * STORAGE_PER_GB_MONTH,
)
def crossover_object_bytes() -> int:
"""Object size at which one request costs as much as transferring it."""
per_request = GET_PER_THOUSAND / 1000.0
per_byte = EGRESS_PER_GB / 1e9
return int(per_request / per_byte)
def compare(patterns: list[ReadPattern], stored_gb: float) -> None:
for pattern in patterns:
cost = monthly_cost(pattern, stored_gb)
print(
f"{pattern.name:<28} ${cost.total:8.2f}/mo "
f"requests ${cost.requests:7.2f} ({cost.request_share:5.1%}) "
f"egress ${cost.egress:7.2f} storage ${cost.storage:7.2f}"
)
Validation
Model output is only as good as the request count fed into it, so measure that rather than estimating it.
# Origin requests per rendered viewport, from access logs over one hour
aws s3api select-object-content --bucket logs --key access/2026-08-07-14.gz \
--expression "SELECT count(*) FROM s3object WHERE _1 LIKE '%REST.GET.OBJECT%'" \
--expression-type SQL --input-serialization '{"CSV":{},"CompressionType":"GZIP"}' \
--output-serialization '{"CSV":{}}' /dev/stdout
Expected ranges: a tile archive behind a healthy CDN should show origin requests at 2–8% of client requests; a Zarr store serving analytical reads should show origin request counts in the low thousands per hour, not the millions. A request count that tracks client actions one-for-one means the cache is not working, and that single fact usually matters more than every other line in the model.
Edge Cases and Caveats
Listing costs on over-partitioned datasets. A LIST is priced roughly ten times a GET, and a query engine resolving a glob over a deeply partitioned prefix may issue thousands of them before reading anything. This is invisible in a model that counts only object reads, and it is the hidden cost of the partition explosion described in partitioning GeoParquet for Athena cost control.
Range requests are still whole requests. Reading 16 KB out of a 4 GB archive costs one GET, the same as reading the whole object. That is what makes tile archives cheap on storage-side accounting and expensive on request-side accounting, and why the range-caching CDN setting is worth more than any layout change.
Storage class changes the request price. Infrequent-access and archive tiers charge substantially more per request and add retrieval fees, so a lifecycle rule that moves hot tiles to a cheaper storage class can raise the total bill. Model the request term per tier before writing the rule, as tiered storage for large spatial datasets sets out.
Frequently Asked Questions
When do GET request charges exceed egress charges?
Below roughly 200 kilobytes per object, on typical public-cloud pricing where a GET costs about $0.0004 per thousand and egress about $0.09 per gigabyte. The crossover is simply the object size at which one request’s fixed cost equals the transfer cost of its payload. Tile archives, Zarr chunks, and over-partitioned Parquet all live near or below that line, which is why request charges surprise teams who budgeted only for egress.
Does a CDN remove request charges or just egress?
Both, at the origin. A cache hit is served by the edge, so it generates neither an origin GET nor origin egress — you pay the CDN’s own request and transfer rates instead, which are usually lower. The number to model is therefore origin requests, which is client requests multiplied by one minus the cache hit rate. At a 95 per cent hit rate that is a twentyfold reduction in the term that was dominating the bill.
Should I make objects bigger to reduce request costs?
Up to the point where over-fetch takes over. Doubling object size halves the request count for sequential reads but doubles the wasted bytes for a read that wants only part of an object. The optimum is where the marginal request saving equals the marginal over-fetch cost, which for most cloud geospatial patterns lands between one and sixteen megabytes — the same band that Zarr chunk sizing and Parquet row group sizing arrive at from the latency side.
Related
- Cloud Cost and Storage Lifecycle — parent guide: the full cost model this term completes
- Object Storage Egress Cost Modelling for Geospatial — the transfer term, modelled the same way
- Tiered Storage for Large Spatial Datasets — how storage class changes both terms at once
- Budget Alerting for Geospatial Storage — catching the moment a layout change moves the request count
← Back to Cloud Cost and Storage Lifecycle