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.

Where request charges stop dominating and egress takes over Two cost curves plotted against object size for a fixed volume of useful data delivered. The request-cost curve falls steeply as objects get larger, because fewer objects are needed. The egress curve rises gently, because larger objects mean more over-fetch for partial reads. They cross at roughly two hundred kilobytes for a typical tiled access pattern. To the left of the crossover, splitting data more finely makes the bill worse; to the right, over-fetch is the thing to control. Tile archives, Zarr chunks, and analytical Parquet are marked at their usual positions along the axis. Cost of delivering a fixed volume of useful data, by object size crossover ≈ 200 KB GET requests fewer objects → fewer requests egress bigger objects → more over-fetch 10 KB 200 KB 4 MB 64 MB object size (log scale) → vector tiles Zarr chunks Parquet reads monthly cost →

Modelling It

python
# 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.

bash
# 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.

Request share of the monthly bill across four read patterns Four horizontal bars, each split into a request portion and an egress portion. A tile archive with no CDN is dominated by requests at about seventy per cent. The same archive behind a CDN drops to about fifteen per cent requests. A Zarr store with one-megabyte chunks is roughly balanced at forty per cent. An analytical GeoParquet scan with large row groups is almost entirely egress at under three per cent requests. The pattern is that small objects and cold caches push the bill into the request term. Where each read pattern's money goes Tile archive, no CDN requests 70% egress Tile archive + CDN 15% egress Zarr, 1 MB chunks 40% egress GeoParquet analytics 3% egress dominates entirely Small objects plus a cold cache is the combination that moves the bill into the request term. Storage class changes the request price, not just the storage price Three storage classes compared on the cost of one thousand GET requests plus the per-gigabyte retrieval fee that some classes add. The standard class charges the least per request and no retrieval fee. Infrequent access charges more per request and adds a retrieval fee. Archive classes charge substantially more on both. A lifecycle rule that moves frequently-read tiles to a cheaper class can therefore raise the total bill. What a colder class does to the request term per 1,000 GET retrieval fee Standard $0.0004 none Infrequent access $0.0010 $0.01 / GB Archive instant $0.0025 $0.03 Move hot tiles to a cold class and the bill rises. Model the request term per tier before writing the rule.

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.


← Back to Cloud Cost and Storage Lifecycle