More than 10 million developers build on Netlify.1 That is more people than New York City counted in 2020. And just like in New York, something is happening every second.
Metering usage at scale makes ingestion a system with real cost and risk. It is a classic event-driven architecture problem. At 10,000 events a second, writing one S3 object per event creates 864 million data-object PUTs a day. Over 30 days, that is 25.92 billion PUTs and a modeled $129,600 in request charges.2
One object per event is affordable at low traffic, and plenty of systems ship it because it makes the durability story simpler. At low traffic, that may be the right trade.
A straightforward trade-off is to batch incoming events before further processing. Use five-second windows based on when each event is ingested, then split a window early when it reaches 10,000 events. At 10,000 events a second, the count limit seals one full batch each second. When traffic is quiet, the five-second window closes the batch.
When does batching pay off?
Batching reduces request cost from the first steady event per second. Whether it pays for its own engineering and compute depends on traffic and the skills available to build and operate it. This table shows the gross S3 request saving before those costs.
| Events/second | PUTs/month per event |
PUTs/month batched |
Cost/month per event |
Cost/month batched |
Gross saving/month |
|---|---|---|---|---|---|
| 1 | 2.592M | 518.4k | $12.96 | $2.59 | $10.37 |
| 10 | 25.92M | 518.4k | $129.60 | $2.59 | $127.01 |
| 100 | 259.2M | 518.4k | $1,296.00 | $2.59 | $1,293.41 |
| 1,000 | 2.592B | 518.4k | $12,960.00 | $2.59 | $12,957.41 |
| 2,000 | 5.184B | 518.4k | $25,920.00 | $2.59 | $25,917.41 |
| 10,000 | 25.92B | 2.592M | $129,600.00 | $12.96 | $129,587.04 |
Storage changes too
PUTs are only one cost. One-event Parquet files also repeat file metadata. The companion repository measured the same Protobuf-shaped usage event written with DuckDB and Zstandard compression.
| 30-day model at 10,000 EPS | One event per file | 10,000 events per file |
|---|---|---|
| Measured Parquet bytes/event | 1,179 B | 22.1 B |
| Data retained for 30 days | 27.79 TiB | 533.84 GiB |
| S3 Standard storage/month | $654.60 | $12.28 |
| Transfer to same-region compute | $0 | $0 |
| One public-internet export | ~$2,470 | ~$48 |
us-east-1. The public-internet row is a read-path example, before AWS’s shared 100 GB free allowance.The batch stores about 98% less data in this fixture. The same-region row is zero because the writer and reader stay in one AWS Region.3 Public exports are different: they are a choice about where data is read, not a cost of ingesting it.
Don’t take my word for it. DuckDB’s
parquet_file_metadata
reports file_size_bytes and footer_size for every object. Compare a
one-event file with a 10,000-event file.
A 10,000-event batch is still only about 216 KiB here. It is a useful landing file, not an ideal analytical file. Compact it later.
An illustrative usage event in a billing ledger
I am building Netlify’s billing ledger and shaping the team around it: an event-sourced ledger where every cent is explainable.
Usage-based products need a small record that can move from a request to a meter without losing its identity, model, token count, or time. Here is one possible schema.
syntax = "proto3";
import "google/protobuf/timestamp.proto";
message UsageEvent {
string event_id = 1;
string team_id = 2;
string model = 3;
uint64 input_tokens = 4;
uint64 output_tokens = 5;
bool billable = 6;
google.protobuf.Timestamp occurred_at = 7; // UTC
}
In the companion repository, WaterDrop publishes this event to Redpanda, Karafka batches it, and DuckDB writes the batch as Parquet to MinIO. The repository includes a load test and a verifier that reports consumer lag and counts stored rows.
The batch write is one statement
The writer decodes the fields into DuckDB columns, then writes one compressed, queryable Parquet object. Rows reach the staging table through DuckDB’s appender, the bulk path rather than one statement per row.
COPY usage_events_stage
TO 's3://ledger/ingested_on=2026-08-22/part-<uuid>.parquet'
(FORMAT PARQUET, COMPRESSION ZSTD);
The Parquet copy options carry the codec and the row group size, so compression is a keyword rather than a library to wire up. S3 API support sends the object to any S3-compatible endpoint. The same statement targets MinIO on a laptop and S3 in production.
Parquet is an open, columnar format built for efficient analytical storage and selective retrieval. It is not a streaming transport: readers need its schema, structure, and types before they can find the column chunks they need.4
Preserve units and precision from emission through ingestion. Otherwise, a quiet data error becomes a billing error, with money and customer trust at stake. AWS’s July 2026 incident was a unit-pricing failure in its estimated-billing computation system.
“Our alarms detected cost anomalies but failed to halt the estimated bill generation process or alert our engineering teams.”
Customers saw false estimates in the billions. The failure was not in Parquet or an ingestion batch, but its warning still applies: validate values and units at every boundary.
At 10,000 events a second, the system adds one second of freshness delay. DuckDB writes each batch as a durable Parquet file that downstream systems can query without performing data gymnastics.
-
Netlify, “Celebrating 10 million developers”, 20 January 2026. ↩
-
S3 Standard in
us-east-1charges $0.005 per 1,000 PUT requests, checked 22 August 2026: S3 pricing and regional price list. A request is charged per object written, so payload size does not change the request count. ↩ -
S3 Standard in
us-east-1charges $0.023 per GiB-month in the first tier, checked 23 August 2026. S3 does not charge data transfer to AWS compute in the same Region. Internet transfer is separate and tiered; the example applies the current public-internet rates to one full export of the 30-day set. S3 pricing, regional price list, and data-transfer price list. ↩ -
The Apache Parquet format specification places file metadata after the data and says readers first read it to find the column chunks they need. ↩