Benchmark · 1 August 2026 · k6

A million-device fleet on one vCPU.

We reproduced the peak-hour update-check traffic of a 1M MAU Expo app against one c6g.medium and an entry-level Postgres, about $60 a month of infrastructure, with code signing and device telemetry both on. 294,372 requests, zero errors, and the capacity probe never found a saturation point.

1 vCPUc6g.medium, 2 GiB, ≈ $60/month with its database
650 req/ssustained, p95 1.39 ms, no saturation reached
938 req/srollout burst absorbed, p95 20.5 ms
0errors and dropped iterations, over 294,372 requests

Setup

Server
EC2 c6g.medium, 1 vCPU Graviton2, 2 GiB, one Docker container, eu-west-3
Database
RDS PostgreSQL db.t4g.small, 2 vCPU, 2 GiB, single-AZ, pool capped at 25
Storage
Google Cloud Storage, another cloud on purpose: update metadata is cached in-process
Generator
EC2 c7g.xlarge, k6 open source, same VPC, 14 of 300 virtual users used
Enabled
RSA-SHA256 signature on every response, device telemetry on every check-in
Excluded
Bundle bytes, served by object storage, and TLS, terminated upstream
Devices
Fixed pool of 100,000 stable ids, 96,143 registered and refreshed during the run

The route under load is GET /manifest. Every device calls it on every app launch, which makes it the only route that scales with the size of your fleet.

It is not a cheap route. The server has to figure out which update this particular device should run: it depends on the release channel, on the runtime version, and on whether that device falls inside the current progressive rollout. That last part is decided per device id, so the answer cannot be computed once and served to everyone from a cache.

Once the answer is known, the response is signed with RSA. And because device telemetry is on, every call also stores health and usage data for the device that made it: which update it runs, platform and runtime version, and when it was last seen. That is what powers the per-update health views, and it means the database takes a write on every request, not just reads. Both features were left on for the whole test. Nothing was turned off to flatter the numbers.

The run

One thing to establish first: what “a 1M MAU app” means in requests per second, because every target below comes from that conversion.

The starting point is simple: expo-updates checks for an update once per app launch. So for a million monthly users, count the launches. In an average app, about 20% of them open it on a given day, around 2.5 times each. That is 500,000 update checks spread over a day — about 6 per second. The busiest hour runs at three times the daily average, which lands at ≈ 20 req/s.

Do the same math for a heavily used app, where half the users open it every day, five times each, with a sharper peak, and the same million users produce ≈ 115 req/s. Those are the two models the fleet phase plays back, and the sizing table at the bottom of the page lets you redo the calculation with your own numbers.

fleet0:00 → 6:0020, then 115, then 230 req/s
Normal life. The peak hour of a 1M MAU app, first with average usage, then with heavy usage (users opening the app 5 times a day), then a 2M MAU app with heavy usage. 95% of devices are already up to date, which is a fleet's everyday state.
probe6:00 → 10:00slow ramp to 650 req/s, held
Finding the ceiling. The rate climbs slowly so the point where latency starts to degrade becomes visible on the chart. It never did: latency stayed flat all the way up.
push_storm10:30 → 16:30peaks at 1,000 app opens/s
The worst credible day. A push notification lands on the whole fleet and a quarter of it opens the app within minutes. Every one of those devices is outdated, so each takes the most expensive path: a full signed manifest.

Requests served (req/s)

02505007501,000
650 held938 peak
0 min5 min10 min15 min

Requests completed, not requests asked for. Zero dropped iterations, so the two are the same line everywhere.

Response time (ms, log scale)

p95p99
1101001,000
cold start20.5 ms
0 min5 min10 min15 min

Flat from 20 to 650 req/s. The spike at t=0 is the process starting cold, p95 98 ms and p99 655 ms for two samples: empty caches, empty pool, first signature. It is left in because it is what a restart under load costs.

CPU (%)

server, 1 vCPUdatabase, 2 vCPU
0255075100
87.8%27%
0 min5 min10 min15 min

Both machines on one clock. The single core ends the storm at 87.8%, close to its ceiling. The database stayed under 27% of two cores and gained CPU credits over the run instead of draining them.

Numbers

PhasePeakp95p99meanmaxdropped
fleet230 req/s1.30 ms2.30 ms2.75 msn/a0
probe650 req/s1.39 ms2.28 ms1.02 ms22.5 ms0
push_storm938 req/s20.52 ms55.2 ms3.22 ms121.3 ms0
Server CPU
87.8% peak, 0.72 core in-process
Server memory
84.3 MB heap, 133 MB RSS, 577 MB of 2 GiB used
Goroutines / fds
320 / 333 peak
Network out
89 Mb/s peak
Database CPU
27% peak, 10.5% average, credits rising 1.53 → 4.78
Database IO
544 write IOPS peak, 1.4 read, 53 disk blocks read all run
Buffer cache hit
99.9995%, on a 47 MB database
Telemetry writes
0 dropped, 0 failed

Sizing

Traffic modelPeak req/s per 1M MAUMAU at 650 req/sWith a 3× margin
Average usage: 20% of users open the app on a given day, 2.5 times each, with a peak hour at 3× the daily average≈ 20≈ 32M≈ 10M
Heavy usage, messaging-like: half of all users open the app daily, 5 times each, peak hour at 4× the average≈ 115≈ 5.6M≈ 1.9M

The conversion is simple: expo-updates checks for an update once per app launch, so launches per second at peak hour is the request rate the server sees. Substitute your own numbers — the measured rate does not move, only the fleet size it corresponds to. Plan with the right-hand column: nobody should size against a measured ceiling.

What it costs

Everything in this test has a public price, so the monthly bill can be written down in full. Two assumptions for the delivery side: you ship 2 OTA releases a month, and a device downloads about 2 MB per release. For a million devices that is roughly 4 TB of bundles delivered per month, served from your bucket through a CDN.

This exact setupPer month
Server — EC2 c6g.medium, the machine in this test≈ $30
Postgres — RDS db.t4g.small with 20 GB of storage≈ $29
Bucket — a few GB of update bundles≈ $1
CDN, option A — CloudFront in front of S3: 4 TB at ≈ $0.085/GB after the free first TB≈ $275
CDN, option B — Cloudflare in front of R2: egress is free, the paid plan is optional$0 – 25
Total, with CloudFront≈ $335
Total, with Cloudflare≈ $60 – 85

One honest note: this is an infrastructure bill, not the full cost. The setup is yours to operate — deploys, monitoring, backups, upgrades. What the benchmark shows is that the machine doing the work does not need to be expensive.

What this does not prove

Endurance
18 minutes. Memory was flat, but a soak test is a separate exercise.
Availability
One instance, no load balancer. Horizontal scaling is untested.
Multi-tenancy
One app, one channel, one runtime version: cache locality is favourable.
Bundle delivery
The test measures manifest resolution and URL signing, not download throughput. In practice this matters little: the recommended setup is that bundles are served by your CDN or straight from the bucket, and the server only delivers bytes as a last resort.
Real clients
Synthetic devices on a private network: no mobile latency or packet loss.

Reproduce

k6 run -e BASE_URL=http://<server>:3000 -e APP_ID=<app-id> \
-e IOS_UPDATE_ID=<id> -e ANDROID_UPDATE_ID=<id> test/load/loadtest.js

The script, the Grafana dashboard, the per-phase summary and the full time series are versioned under test/load/. Every chart here is drawn from that CSV, unsmoothed. Open it on GitHub ↗