> For the complete documentation index, see [llms.txt](https://mercure-technologies.gitbook.io/xprem/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mercure-technologies.gitbook.io/xprem/references/benchmark.md).

# Benchmark

The numbers below come from one load test run on 1 August 2026. Everything on this page was measured, and the run can be reproduced from the script and the raw results published in the repository.

### What was measured

The expo-updates protocol has one endpoint that every device calls on every app launch, `GET /manifest`. The client says which app, platform, runtime version and channel it is on, along with the update it currently runs, and the server answers either a manifest for a newer update or a signed *no update available* directive. That single route is the only thing whose load grows with the size of your fleet, so it is what the test hammers.

Update bundles are not served by the process. Manifests point at your object storage, so the bytes of a release are delivered by the storage layer or by a CDN in front of it, and the server only resolves and signs the URLs. TLS is also excluded, because a real deployment terminates it upstream in a load balancer or reverse proxy.

#### Configuration under test

| Component      | Specification                                        |
| -------------- | ---------------------------------------------------- |
| Server         | AWS EC2 `c6g.medium`, 1 vCPU (Graviton2), 2 GiB RAM  |
| Database       | AWS RDS PostgreSQL `db.t4g.small`, 2 vCPU, 2 GiB RAM |
| Update storage | Google Cloud Storage                                 |
| Load generator | AWS EC2 `c7g.xlarge`, same VPC, k6 (open source)     |

Code signing and device telemetry were both enabled, which means every response was RSA-signed and every device was written to the identity registry. Nothing was switched off to produce these figures.

#### How the load was applied

Requests come from a fixed pool of 100,000 devices with stable identifiers, because a real fleet has stable device ids and an endless stream of fresh ones would simulate a fleet nobody has. The target rate is imposed whether or not the server keeps up, so queueing shows up as latency instead of being hidden by a client that slows down with the server.

The run has three phases. The first replays the peak hour of real fleets at 20, then 115, then 230 requests per second. The second ramps slowly to 650 requests per second to find the point where latency departs from its baseline. The third simulates a push notification sent to an entire fleet, where every device that opens the app is outdated and takes the expensive path.

### Results

The run served 294,372 requests with no HTTP error and no request the generator failed to inject.

| Phase              | Peak rate | p95     | p99     | Mean    |
| ------------------ | --------- | ------- | ------- | ------- |
| Real fleet traffic | 230 req/s | 1.30 ms | 2.30 ms | 2.75 ms |
| Capacity probe     | 650 req/s | 1.39 ms | 2.28 ms | 1.02 ms |
| Full-fleet rollout | 938 req/s | 20.5 ms | 55.2 ms | 3.22 ms |

Latency did not move between 20 and 650 requests per second. The probe was built to find a saturation point and did not find one, so 650 requests per second is a floor on what this configuration serves rather than a ceiling.

The first seconds of a run are slower than the rest. At startup the caches are empty, the connection pool has yet to open and the first signature is being computed, which measures around 98 ms at p95 before falling to a few milliseconds within thirty seconds. Plan for it if you restart under load.

#### Where the work happens

| Server (1 vCPU) | Peak            | Database (2 vCPU)      | Value                   |
| --------------- | --------------- | ---------------------- | ----------------------- |
| CPU             | 87.8%           | CPU                    | 27% peak, 10.5% average |
| Go heap         | 84.3 MB         | Connections            | 25 peak, 15.5 average   |
| Process memory  | 133.2 MB        | Write IOPS             | 544 peak, 159 average   |
| Machine memory  | 577 MB of 2 GiB | Buffer cache hit ratio | 99.9995%                |

The server is the component doing the work. It reached 87.8% of its single core during the rollout phase, which means it was serving close to its physical limit and still answering in tens of milliseconds. The ceiling of that machine is not far above that phase, so a second vCPU is what a larger fleet needs, and it needs it on rollout days rather than every day.

The database was never the constraint. Read IOPS stayed near zero and the buffer cache hit ratio reached 99.9995%, which means the working set lives entirely in memory. Device telemetry is real work, up to 544 write IOPS at the peak, and it was absorbed without a single lost write and without touching request latency, because those writes are drained by a bounded worker pool that sits off the request path.

### Sizing your own deployment

A figure in requests per second is only useful once you can map it onto your own fleet. Since a client checks for an update on each app launch, the conversion depends on how often your users open the app.

| Model                 | DAU / MAU | Sessions per day | Peak factor | Peak req/s per 1M MAU |
| --------------------- | --------- | ---------------- | ----------- | --------------------- |
| Typical app           | 20%       | 2.5              | 3x          | around 20             |
| Frequently opened app | 50%       | 5                | 4x          | around 115            |

Applying those models to the measured rate gives the fleet sizes below. The right-hand column keeps a threefold margin, which is the column to size against, since nobody should plan to run at a measured ceiling.

| Model                 | At measured capacity | With a 3x margin |
| --------------------- | -------------------- | ---------------- |
| Typical app           | around 32M MAU       | around 10M MAU   |
| Frequently opened app | around 5.6M MAU      | around 1.9M MAU  |

Substitute your own coefficients if your app behaves differently. The measured capacity in requests per second does not change, only the fleet size it corresponds to.

{% hint style="info" %}
The only cost that grows with the size of your fleet is the transfer of update bundles on release day, since devices download them from your object storage. Server and database capacity stay flat.
{% endhint %}

### What this run does not establish

These figures come from a single run of eighteen minutes, so they say nothing about endurance, memory leaks or slow drift over days. They cover one app, one channel and one runtime version, which gives better cache locality than a deployment hosting many apps at once. They were produced by a single instance with no load balancer, so horizontal scaling remains untested and nothing here is a claim about availability. Finally, the clients are synthetic and sit on a private network, so the latency measured is the work done by the server and not what a phone on a mobile network experiences.

### Reproducing the run

The k6 script, the Grafana dashboard used to watch the runs, the per-phase summary and the full time series live in the repository under `test/load/`. The time series is a CSV with one point per row, so any charting tool reads it without a parser.

```bash
k6 run -o experimental-prometheus-rw \
  --tag testid=<run-name> \
  -e BASE_URL=http://<server>:3000 \
  -e APP_ID=<app-id> \
  -e IOS_UPDATE_ID=<ios-manifest-id> \
  -e ANDROID_UPDATE_ID=<android-manifest-id> \
  test/load/loadtest.js
```

Every run reports a `dropped_iterations` counter. A run whose generator could not inject the requested load is not a measurement of the server, so publish that counter alongside any figure you produce.

To watch a run the way these figures were collected, see [Prometheus & Grafana](/xprem/monitoring/prometheus-and-grafana.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://mercure-technologies.gitbook.io/xprem/references/benchmark.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
