36Performance
zplCore.com generates 200,000+ real shipping labels per second (100 × 150 mm, 33 elements, QR + Code 128 + GS1-128) from dictionary or JSON records into a ZPL file – under JIT and NativeAOT, measured on an Apple M3 Max (14 cores), .NET 10, median of 5 runs, samples/ShippingBench.
Real shipping label (samples/ShippingBench)
Measured on 21 Oct 2026: Apple M3 Max (10 performance + 4 efficiency cores), macOS 26.6, .NET 10, Release, workstation GC; one warm-up run, then the median of 5 runs. End to end: records → ZPL → file on disk (StreamWriter, 64 KB buffer). All data sources write a byte-identical 131 MB file (SHA-256 checked before measuring), under JIT and NativeAOT alike.

The label: samples/templates/printjob.json, a 100 × 150 mm (4 × 6 in) shipping label with 33 elements – sender block, inverted service box, recipient (country via expression), routing code, weight, parcels, ship date (Format(ToDate([ShipDate]), "dd.MM.yyyy")), QR code (tracking URL), Code 128 (tracking number), GS1-128 SSCC, page counter, frames and rules – plus a start and an end label. The records vary like real data (umlauts, double names, 9 places in AT/DE/CH/IT, 6 services, 2.5–29 kg, valid SSCCs). Downloads: label as PDF / ZPL, print job (start + 3 shipments + end) as PDF / ZPL (300 dpi), image PNG 300 dpi.
| Data source (100,000 labels + start/end → ZPL file) | JIT labels/s | NativeAOT labels/s | Time |
|---|---|---|---|
IReadOnlyDictionary<string, object?> records | 249,286 | 238,590 | 0.40–0.42 s |
JSON records (JsonElement) | 216,171 | 213,637 | 0.46–0.47 s |
POCO with Map() per field | 128,165 | 91,784 | 0.78–1.09 s |
POCO, For<T>() (reflection) | 124,995 | 83,994 | 0.80–1.19 s |
Stored format (^DF/^XF), POCO | 109,531 | 72,113 | 0.91–1.39 s |
| Rendering, 5,000 labels, 14 cores, 203 dpi: PNG preview | 1,224 | 1,182 | |
| Rendering, 5,000 labels, 14 cores, 203 dpi: PCX (1 bit) | 1,058 | 462 |
For mass data, pass dictionaries or JSON records: they stay above 200,000 labels/s under JIT and NativeAOT (slowest single run 208,068/s). POCO binding reaches about 125,000/s under JIT and 84,000/s under NativeAOT.
5 million records and Docker (NativeAOT): records as JSON Lines (1.56 GB, one record per line), read line by line, bound as JsonElement, streamed into a 6.53 GB ZPL file. Same Apple M3 Max; Docker Desktop runs the container in a VM, so file I/O to the volume is slower than native.
| Run | Labels/s | Time | Peak memory |
|---|---|---|---|
| NativeAOT on macOS, 5 million records | 132,164 | 37.8 s | 248 MB RSS |
Docker, NativeAOT linux-arm64, --memory 1g, 5 million records | 91,133 | 54.9 s | 90 MB |
| Docker, 100,000 labels, dictionary / JSON records | 157,489 / 119,271 | 0.64 / 0.84 s | |
| Docker, PNG preview, 14 cores | 1,164 |
Memory stays constant for any number of records (a streamed source is neither counted nor buffered). Image build/Dockerfile.shippingbench: 127 MB, runtime-deps:10.0-noble-chiseled-extra, native binary only, non-root.
dotnet run -c Release --project samples/ShippingBench # JIT dotnet publish samples/ShippingBench -c Release -r osx-arm64 -p:PublishAot=true -o out # NativeAOT docker build -f build/Dockerfile.shippingbench -t zplcore-shippingbench . docker run --rm -v zplcore-bench:/data zplcore-shippingbench 1000 10 1 5000000 # + 5 million JSON Lines records dotnet run -c Release --project samples/ShippingBench -- sample out/sample # label as PNG/PDF/ZPL
Small 7-element label (samples/PerfTest)
samples/PerfTest generates 100,000 labels in a single print job (start label + 100,000 labels + end label, cut only after the last label) – from objects, streamed from EF Core/SQLite and from LiteDB. It uses a small 100 × 60 mm label with 7 elements (text, expression, number format, Code 128, counter), not the shipping label above. Measured in a Release build on 14 cores (Apple M3 Max, .NET 10), single run.
| Test (100,000 labels, one job) | Time | Labels/s | File | Heap |
|---|---|---|---|---|
Objects → ZPL, stored format (^DF/^XF) | 0.73 s | 136,000 | 14.7 MB | 24 MB |
| Objects → ZPL, full format per label | 0.39 s | 256,000 | 29.1 MB | 24 MB |
EF Core / SQLite AsAsyncEnumerable() → file | 0.57 s | 176,000 | 14.7 MB | 29 MB |
| LiteDB query → file | 0.50 s | 202,000 | 14.7 MB | 64 MB |
| PNG preview, sequential (799 × 480 px, grayscale PNG) | ≈ 3.4 min | 490 | 18.2 MB / 2,000 | 84 MB |
| PNG preview, parallel (14 cores) | ≈ 25 s | 4,033 | 17.1 MB / 2,000 | 106 MB |
Generating ZPL for the printer is practically free and streams with constant memory. The stored format halves the amount of data transferred.
The ZPL tests bind classes (Shipment objects) and therefore generate sequentially; dictionary, JSON and DataRow records are generated on all cores – see parallel job generation.
Render optimization
Black-and-white labels are written as 8-bit grayscale PNG (with anti-aliasing) or true 1-bit PNG (AntiAlias = false) instead of 32-bit RGBA (ZplRenderSettings.CompactPng, PngCompression Fastest/Optimal/SmallestSize). Simple ^GB boxes are drawn directly, and the label image is only copied when ^MC N needs it.
| Per label (100 × 60 mm, 203 dpi) | before | now |
|---|---|---|
| Render to bitmap | 1.28 ms | 0.39 ms |
| Render + PNG | 8.19 ms | 1.86 ms |
| PNG size | 18 KB | 9 KB |
| 100,000 previews in parallel | 2.2 min | 25 s |
Parallel scaling
SkiaSharp serializes two things globally: Skia's glyph cache (every DrawText) and SkiaSharp's handle table (every newly created SKPaint/SKFont/SKBitmap – and DrawBitmap, which internally creates an SKImage every time). zplCore.com therefore rasterizes each glyph once per font/size into a lock-free cache of SKImage masks and draws text via DrawImage; fonts are passed through explicitly instead of being queried via SKFont.Typeface. Results measured with samples/RenderBench -- 150 scale (Apple M3 Max, 10 performance + 4 efficiency cores):
| Threads | Render + PNG before | Render + PNG now | Text only before | Text only now |
|---|---|---|---|---|
| 1 | 499/s (1.0×) | 549/s (1.0×) | 5,665/s (1.0×) | 6,306/s (1.0×) |
| 4 | 1,984/s (4.0×) | 2,225/s (4.1×) | 3,770/s (0.7×) | 28,183/s (4.5×) |
| 8 | 2,245/s (4.5×) | 4,090/s (7.5×) | 3,162/s (0.6×) | 42,434/s (6.7×) |
| 10 | 1,826/s (3.7×) | 5,022/s (9.2×) | 3,150/s (0.6×) | 39,384/s (6.2×) |
dotnet run -c Release --project samples/RenderBench -- 150 scale [label|text|barcode|box|empty]
dotnet run -c Release --project samples/RenderBench -- 500 # micro-benchmark per element
dotnet run -c Release --project samples/PerfTest -- 100000 perf-out
Web API throughput
ASP.NET Core minimal API from samples/WebApiDemo: 14 cores (Apple M3 Max), server GC, 14 workers, small 100 × 60 mm label at 203 dpi (not the shipping label above); client and server in the same process.
| Scenario | Labels/s | MB/s | p50 | p95 | GC heap |
|---|---|---|---|---|---|
| JSON payload → ZPL, 1,000 records/request | 62,857 | 20.3 | 16.2 ms | 16.7 ms | 178 MB |
| JSON payload → ZPL, 10,000 records/request | 112,484 | 36.4 | 76.1 ms | 136.9 ms | 183 MB |
| JSON payload → ZPL, 100,000 records/request | 186,572 | 60.5 | 527.5 ms | 565.2 ms | 414 MB |
| DB streaming → ZPL, 10,000 records | 151,744 | 49.1 | 69.6 ms | 73.1 ms | 361 MB |
| DB streaming → ZPL, 100,000 records | 199,102 | 64.6 | 504.3 ms | 505.2 ms | 372 MB |
| JSON 1k, 1 / 4 / 16 clients | 196,112 / 240,453 / 155,344 | 63.4 / 77.7 / 50.2 | 4.8 / 16.4 / 100.8 ms | 8.6 / 21.7 / 120.8 ms | 404–419 MB |
| DB 10k, 4 / 16 clients | 215,699 / 179,860 | 69.7 / 58.2 | 186.2 / 878.0 ms | 202.4 / 907.6 ms | 395–408 MB |
| Preview, 50 labels/request: PDF 1 / 14 clients | 583 / 5,085 | 5.0 / 43.5 | 87.0 / 131.8 ms | 94.0 / 154.4 ms | 267–472 MB |
| Preview, 50 labels/request: PNG (zip) 1 / 14 clients | 484 / 3,995 | 4.6 / 38.1 | 108.3 / 162.0 ms | 108.7 / 188.1 ms | 269–473 MB |
| Job queue: 256 × JSON 1k → ZPL (153 jobs/s) | 153,438 | 49.6 | 98.8 ms | 365.5 ms | 885 MB |
| Job queue: 64 × DB 10k → ZPL (18.6 jobs/s) | 186,361 | 60.3 | 2,236.5 ms | 3,411.1 ms | 828 MB |
| Job queue: 64 × JSON 50 → PDF (104 jobs/s) | 5,396 | 46.2 | 345.2 ms | 445.6 ms | 467 MB |
ZPL generation saturates at about 199,000 labels/s (from 4 parallel requests); more clients only add latency. Rendering costs about 40× more and scales with the cores. Queue latency includes the wait time (all jobs submitted at once).
Memory and soak test
| Workload | Labels | Memory |
|---|---|---|
| Soak test, small labels, rendered in parallel | 1,000,000 | RSS 128–138 MB · GC heap 2–3 MB, flat (no leak) |
| Soak test, 4 × 6″ PNGs, 14 threads | 1,000,000 | RSS 438–481 MB, flat |
| ZPL streaming from objects | 100,000 | heap 24 MB |
| Streaming from EF Core / SQLite into a ZPL file | 100,000 | heap 29 MB |
| Streaming from LiteDB into a ZPL file | 100,000 | heap 64 MB |
All figures are measurements with the benchmarks in the repository, not a guarantee.

