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.

Batching the ingest stream A batch seals on the five-second window or the 10,000-event limit, whichever comes first. Usage events 10,000 per second Batch window Freshness cost 1.0 s Count 10,000 / 10,000 Elapsed 1.0 s / 5.0 s The 10,000-event limit seals the batch then the next window opens Durable storage One Parquet object 1 PUT from 10,000 events $129,600.00$12.96 per month in S3 PUTs 25.92B PUTs become 2.592M · gross saving $129,587.04 per month
Steady event rate
events per second

Pick a rate and watch the two limits race. Below 2,000 events per second the five-second window seals the batch; at 2,000 both limits arrive together; above it the count limit seals first and the request reduction holds at 10,000×. Every figure matches the cost table below.

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
Steady event rate, 30 days, and one PUT per object. Below 2,000 EPS, the five-second window closes the batch; at 2,000 EPS the batch reaches 10,000 events. Above that point, the count limit closes batches and the request reduction stays at 10,000×.

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
Controlled DuckDB measurement with the companion schema and Zstandard compression. The fixture uses a UUID, fixed team and model values, fixed token counts, and distinct timestamps. Storage assumes a steady 30-day retained set in 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
}
A sample Protobuf contract for a usage event in a billing ledger.

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);
One sealed batch becomes one compressed Parquet object. The name is a UUID, so concurrent consumers never need a shared counter.

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.

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.

  1. Netlify, “Celebrating 10 million developers”, 20 January 2026. 

  2. S3 Standard in us-east-1 charges $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. 

  3. S3 Standard in us-east-1 charges $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

  4. The Apache Parquet format specification places file metadata after the data and says readers first read it to find the column chunks they need.