The short answer: at 1,000 input rows nothing took longer than 131 milliseconds. At 10,000 it depends on the dataset — raw records as CSV took 224 milliseconds, variance summary as XLSX took 1.20 seconds. Almost all of that wait is the server building the file. Across all 96 exports the longest anyone waited in the queue was 11 milliseconds, and the gap between "requested" and "downloadable" was only 21–124 milliseconds — most of which is the 100-millisecond poll that notices the job finished.
Slowest group measured
1.20 s
10,000 rows · Variance summary · XLSX
Fastest group measured
123 ms
1,000 rows · Variance summary · XLSX
Longest queue wait
11 ms
the worst case across all 96 exports
Files reconciled
72 of 72
every downloaded file was checked line by line
Every wait below is a median; maxima are shown alongside it where they matter. Each group has only 3 samples, which is far too few to publish a trustworthy p95 or p99, so none is claimed, and nothing here is a concurrency capacity.
1. How long from clicking export to being able to download
Changing format inside one dataset moves the wait far less than changing dataset does.
1,000 input rows: request to first observed downloadable, median. Bars are scaled against the slowest group here, which is highlighted.
10,000 input rows: request to first observed downloadable, median. Bars are scaled against the slowest group here, which is highlighted.
The measurement is split into five separate clocks, and the tables below break them out:
- Admitted — the request returning a job id: 9.75–15.79 milliseconds.
- Queued — waiting for a background worker to pick the job up. The worst single wait across all 96 exports was 11.45 milliseconds, and every median was under 10.55 milliseconds. The queue is not the bottleneck.
- Executed — fetching the rows, sorting them, writing the file, uploading it and updating the published report. This is 26–97% of the wait from request to downloadable.
- Observed downloadable — the driver polls the job every 100 milliseconds until it first sees it completed. That polling is where the remaining 21–124 milliseconds goes.
- Downloaded — timed separately, pulling the finished file back from storage. It is never folded into the waits above.
These numbers come from a Release build of the API running natively on macOS, talking to its own PostgreSQL and Azurite, going through the real JobReport and shared reports queue rather than calling the renderer directly.
2. Choosing the dataset and the format
At 10,000 rows, picking the wrong dataset costs about 4.9× — picking the wrong format costs far less.
| Dataset (10,000 rows) | CSV | TXT (fixed width) | XLSX | Largest / CSV |
|---|---|---|---|---|
| Raw records inventory-raw-data | 224 milliseconds 1.06 MiB | 333 milliseconds 6.73 MiB | 445 milliseconds 328.4 KiB |
6.4× |
| Inventory summary inventory-summary | 1.09 seconds 556.7 KiB | 1.09 seconds 5.78 MiB | 1.20 seconds 312.1 KiB |
11× |
| Location and product summary location-product-summary | 666 milliseconds 507.9 KiB | 660 milliseconds 4.83 MiB | 665 milliseconds 253.4 KiB |
9.7× |
| Variance summary variance-summary | 1.09 seconds 761.8 KiB | 1.20 seconds 7.69 MiB | 1.20 seconds 377.9 KiB |
10× |
The dataset matters, the format mostly does not. Taking the fastest format available for each dataset, the wait at 10,000 rows runs from 224 milliseconds to 1.09 seconds — a factor of 4.9×. Inside a single dataset the three formats differ by at most 2.0× (Raw records: 224 milliseconds at best against 445 milliseconds at worst), and for Location and product summary the choice of format made no practical difference at all.
What the format really changes is the size of the file and how long it takes to download, not how long the server takes to build it. TXT pads every column out to a fixed width, so at 10,000 rows the four datasets together come to 25.02 MiB against 2.84 MiB for the same content in CSV — a factor of 8.8×. XLSX compresses and is smallest at 1.24 MiB. Bigger files take longer to fetch: at 10,000 rows the median download was 8.4–13.4 milliseconds for CSV, 22.3–29.3 for TXT and 7.8–10.9 for XLSX.
Worth knowing: if the file is going to a person rather than back into a system, XLSX was both the smallest and the fastest to download here. CSV is the more practical choice for scripts and re-imports. TXT's fixed-width layout exists to line columns up for printing, not to move data efficiently — the same 10,000 rows are 6.4–10.6 times larger than CSV, so it is worth confirming someone actually needs aligned columns before choosing it.
3. What happens when the input grows ten times
Two different kinds of dataset, two different kinds of growth.
Execution time when the input grows from 1,000 rows to 10,000, a factor of ten. Bars are scaled against the worst dataset.
Raw records grow more slowly than the input; the other three grow with it, or a little worse. Inventory summary was the most expensive, taking 10× to 16× longer from 1,000 rows to 10,000. Raw records only took 5.8× to 7.7×, because at 1,000 rows its fixed costs — creating the job, reading the catalogue snapshot, finishing the file — are a large share of a small job, and 10,000 rows amortise them.
| Dataset (10,000 rows) | SQL calls, median | Share of execution spent in SQL | Input rows per second | Per-row cost, 1,000 vs 10,000 |
|---|---|---|---|---|
| Raw records | 348–382 | 47–55% | 27,733–51,146 | 0.6× |
| Inventory summary | 723–747 | 37–42% | 8,840–9,460 | 1.0× |
| Location and product summary | 416–418 | 29–34% | 16,484–18,450 | 1.2× |
| Variance summary | 723–747 | 35–41% | 8,732–9,491 | 1.4× |
Per-row cost is milliseconds of execution per input row, comparing the CSV export at 1,000 rows with the same at 10,000 rows. Above 1× means each row got more expensive at ten times the size; below it means the fixed costs were spread more thinly. Raw records is the interesting case: its rows actually got cheaper, because at 1,000 rows most of the work was not per-row work at all. The three summary reports have to materialise every row and then match and roll it up in memory, which is why each of their rows costs several times more than a row of the raw export.
4. What the database and the app did while it worked
The cost of producing these files, as measured rather than estimated.
| Measure | Measured | What it covers |
|---|---|---|
| API sampled RSS peak | 1363.56 MiB | the peak was reached during an export itself, not only between them; sampling spans warmup, HTTP polling and file verification, so this is neither managed live memory nor container memory |
| Driver RSS peak | 425.88 MiB | the Node driver on the same host |
| Allocated during measurement | 11.79 GiB | System.Runtime counters cover the whole measurement window and cannot be attributed to one export |
| GC pause, total | 0.16 seconds | across all 96 exports; sampled GC committed peak 1105.17 MiB |
| API process CPU | 40.13 seconds | same sampling window |
| Resource samples | 112 | one every 500 milliseconds, 0 of them failed; 776 System.Runtime samples |
| PostgreSQL temporary files | 0 bytes | no query at 10,000 rows or below spilled to a temporary file. The application's own external-sort disk peak was not collected |
| Dataset and format (10,000 rows) | SQL calls, median | SQL execution, median ms | PostgreSQL temp bytes, max |
|---|---|---|---|
| Raw records · CSV | 348 | 107.47 | 0 |
| Raw records · TXT (fixed width) | 372 | 146.10 | 0 |
| Raw records · XLSX | 382 | 180.34 | 0 |
| Inventory summary · CSV | 723 | 442.55 | 0 |
| Inventory summary · TXT (fixed width) | 739 | 389.03 | 0 |
| Inventory summary · XLSX | 747 | 461.21 | 0 |
| Location and product summary · CSV | 418 | 184.67 | 0 |
| Location and product summary · TXT (fixed width) | 416 | 176.96 | 0 |
| Location and product summary · XLSX | 416 | 204.73 | 0 |
| Variance summary · CSV | 723 | 431.34 | 0 |
| Variance summary · TXT (fixed width) | 739 | 395.71 | 0 |
| Variance summary · XLSX | 747 | 464.51 | 0 |
The database figures include the HTTP admission, the status polling, the download and the verification queries. They are not the report builder's SQL time on its own, and at 10,000 rows SQL accounts for 29–55% of execution time depending on the dataset.
Do not read the memory figures as a pass mark. Later samples share one process, its retained heap and a warm database cache, so the RSS peak includes memory left behind by earlier exports and is not a managed live size. This run makes no claim about a memory target being met, and none of these numbers is evidence of a leak. Answering "how much memory does one export need" needs a separate run that forces a collection between samples.
5. Every group, one row each
All 24 measured groups behind everything above. Nothing is sampled out.
| Input rows | Dataset | Format | Admitted ms | Queued ms | Executed ms, median / max | Request to downloadable ms, median / max | Downloaded ms | Output KiB | Input rows per second |
|---|---|---|---|---|---|---|---|---|---|
| 1,000 | Raw records inventory-raw-data | CSV | 15.79 | 10.55 | 33.91 / 39.73 | 128.93 / 134.15 | 12.54 | 107.39 KiB | 29,485 |
| 1,000 | Raw records inventory-raw-data | TXT (fixed width) | 14.65 | 9.20 | 44.38 / 46.94 | 127.95 / 129.05 | 12.96 | 689.45 KiB | 22,534 |
| 1,000 | Raw records inventory-raw-data | XLSX | 13.61 | 8.78 | 46.89 / 60.50 | 127.80 / 129.32 | 9.89 | 35.15 KiB | 21,327 |
| 1,000 | Inventory summary inventory-summary | CSV | 13.97 | 8.71 | 104.74 / 117.58 | 125.69 / 237.23 | 6.78 | 55.72 KiB | 9,547 |
| 1,000 | Inventory summary inventory-summary | TXT (fixed width) | 12.82 | 8.25 | 81.06 / 103.56 | 123.22 / 124.99 | 7.59 | 591.80 KiB | 12,336 |
| 1,000 | Inventory summary inventory-summary | XLSX | 13.45 | 8.05 | 70.61 / 109.63 | 126.56 / 244.68 | 8.46 | 33.30 KiB | 14,162 |
| 1,000 | Location and product summary location-product-summary | CSV | 14.21 | 9.46 | 44.60 / 46.02 | 130.23 / 130.58 | 11.89 | 50.83 KiB | 22,421 |
| 1,000 | Location and product summary location-product-summary | TXT (fixed width) | 14.41 | 8.96 | 46.41 / 49.45 | 128.31 / 128.63 | 12.58 | 494.14 KiB | 21,546 |
| 1,000 | Location and product summary location-product-summary | XLSX | 15.36 | 9.04 | 50.03 / 51.59 | 131.20 / 131.56 | 12.08 | 27.40 KiB | 19,988 |
| 1,000 | Variance summary variance-summary | CSV | 13.03 | 7.99 | 77.84 / 81.47 | 124.94 / 126.86 | 7.85 | 76.25 KiB | 12,847 |
| 1,000 | Variance summary variance-summary | TXT (fixed width) | 12.33 | 7.76 | 92.72 / 96.05 | 124.04 / 124.46 | 8.78 | 787.11 KiB | 10,785 |
| 1,000 | Variance summary variance-summary | XLSX | 12.98 | 8.20 | 85.16 / 91.70 | 122.97 / 123.20 | 6.40 | 39.85 KiB | 11,743 |
| 10,000 | Raw records inventory-raw-data | CSV | 9.75 | 5.81 | 195.52 / 197.12 | 223.54 / 227.62 | 8.96 | 1085.03 KiB | 51,146 |
| 10,000 | Raw records inventory-raw-data | TXT (fixed width) | 10.14 | 5.83 | 308.61 / 340.86 | 332.87 / 440.43 | 24.66 | 6894.53 KiB | 32,404 |
| 10,000 | Raw records inventory-raw-data | XLSX | 13.84 | 8.74 | 360.58 / 383.68 | 445.23 / 446.75 | 7.84 | 328.42 KiB | 27,733 |
| 10,000 | Inventory summary inventory-summary | CSV | 13.36 | 8.44 | 1057.09 / 1066.86 | 1089.95 / 1091.40 | 8.43 | 556.70 KiB | 9,460 |
| 10,000 | Inventory summary inventory-summary | TXT (fixed width) | 11.12 | 6.72 | 1061.72 / 1136.96 | 1089.69 / 1201.39 | 22.26 | 5917.97 KiB | 9,419 |
| 10,000 | Inventory summary inventory-summary | XLSX | 15.12 | 9.83 | 1131.21 / 1133.12 | 1199.37 / 1203.67 | 10.93 | 312.08 KiB | 8,840 |
| 10,000 | Location and product summary location-product-summary | CSV | 14.35 | 8.65 | 542.01 / 563.87 | 666.21 / 666.41 | 13.24 | 507.87 KiB | 18,450 |
| 10,000 | Location and product summary location-product-summary | TXT (fixed width) | 10.06 | 6.14 | 601.90 / 619.39 | 660.01 / 666.92 | 23.29 | 4941.41 KiB | 16,614 |
| 10,000 | Location and product summary location-product-summary | XLSX | 14.65 | 9.51 | 606.63 / 626.34 | 664.99 / 668.52 | 8.90 | 253.43 KiB | 16,484 |
| 10,000 | Variance summary variance-summary | CSV | 13.09 | 7.30 | 1053.62 / 1067.48 | 1087.13 / 1089.83 | 13.43 | 761.79 KiB | 9,491 |
| 10,000 | Variance summary variance-summary | TXT (fixed width) | 10.25 | 5.94 | 1119.48 / 1150.16 | 1197.97 / 1203.94 | 29.35 | 7871.09 KiB | 8,933 |
| 10,000 | Variance summary variance-summary | XLSX | 14.24 | 9.16 | 1145.26 / 1154.89 | 1203.71 / 1205.39 | 9.55 | 377.90 KiB | 8,732 |
Each group is 3 measured samples; the 1 warmup is excluded. "Request to downloadable" includes the 100-millisecond polling interval, and "downloaded" is timed separately afterwards. Every output file reconciled against the source data.
6. What this did not test
Being straight about the edges of this test, because they decide what we should not promise yet.
- Larger inputs. Only 1,000 and 10,000 rows were tested. 100,000 rows was not, so this page gives no answer to "how many rows can it handle".
- No old-version comparison. There is a single point, at
a21df821, and nothing to compare it against. No speed-up is claimed and this is not the full S5 acceptance run. - This is not a capacity ceiling. Jobs ran one at a time, so rows per second is a single job's processing rate, not sustainable throughput. Nothing here shows what happens when several people export at once.
- PDF and mixed workloads. Only CSV, TXT and XLSX were measured. PDF reports, catalogue snapshot builds and product imports were not running alongside.
- Harder data. Matching was 100%, one location per job and one record per key throughout. Long text fields, wide 256-column exports, ambiguous codes needing a candidate list and hot products were not covered.
- Our production cloud setup. The API and driver ran natively on a Mac; PostgreSQL and Azurite shared a 4 vCPU / 8192 MiB Podman VM with the development containers. That is a local baseline, not a cloud-sized comparison.
- Nothing was deployed. This run only establishes a baseline for the current version. The later release step was cancelled and nothing was published or rolled back.
7. How to trust this
- Every file was checked line by line. The downloaded CSV, TXT and XLSX were compared row by row on row count, order and every field value (record id, location, submitted code, key, external id, SKU, barcode, product name, quantities, price and the book, counted and variance figures). XLSX went further: the ZIP entries' CRC values and cell values were checked, and the first measured export was downloaded a second time and compared by SHA-256. All 72 measurements passed.
- The input was reconciled too. Before generation, the 10,000-row job's process and quantity-on-hand counts and totals were verified against the database, along with the identity of every record.
- It ran through the real path. A real supervisor in the target organisation, a normal JobReport, the shared reports queue, a blob upload and the published-report update. No platform identity, no permission bypass, no calling the renderer directly, and no fabricated subscription or licence records — entitlements were prepared normally through the payment Simulator.
- Its own database and storage. Containers were created with a random run label, and the 2 resources this run created were checked and removed by exact identity afterwards. The development environment's containers and data were left untouched.
- The sample size is stated, not hidden. 3 measured samples per group, warmups excluded, medians and maxima only, and an explicit statement that a trustworthy p95 does not exist at this sample size.
- Timing came from the system's own records. Queue and execution times are read from timestamps the server persisted, not estimated by the driver; admission, observation and download are timed separately.
Run def26ed0 · version 20261011-exports · code under test a21df821 (with uncommitted changes to the experiment scripts) · measured 2026-10-11 · 1.69 minutes end to end, including build, setup and cleanup · fixed catalogue of 10,000 products, 100% matching. Every figure on this page was generated by tests/performance/20261011-exports/page.mjs from the measured result files in tests/performance/20261011-exports/reports; none is typed in by hand.