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.
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)
Requests completed, not requests asked for. Zero dropped iterations, so the two are the same line everywhere.
Response time (ms, log scale)
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 (%)
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
| Phase | Peak | p95 | p99 | mean | max | dropped |
|---|---|---|---|---|---|---|
| fleet | 230 req/s | 1.30 ms | 2.30 ms | 2.75 ms | n/a | 0 |
| probe | 650 req/s | 1.39 ms | 2.28 ms | 1.02 ms | 22.5 ms | 0 |
| push_storm | 938 req/s | 20.52 ms | 55.2 ms | 3.22 ms | 121.3 ms | 0 |
- 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 model | Peak req/s per 1M MAU | MAU at 650 req/s | With 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 setup | Per 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.jsThe 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 ↗