Platforms & cloud
Runs wherever your backend runs
One .NET 10 SDK, no printer driver, shipped with dotnet publish like any other dependency: on Windows, Linux and macOS, in IIS, in Docker containers and Kubernetes clusters, on-premises or in clouds such as Azure and AWS.
- Windows
- Linux
- macOS
- Docker
- Kubernetes
- Raspberry Pi
- Synology NAS
- IIS
- Azure
- AWS
Windows · Linux · macOS
A single .NET 10 assembly built on SkiaSharp and HarfBuzz. The automated test suite passes on Windows 11, macOS and Linux. No printer driver and no GUI required.
Containers, NAS & Pi
Tested in the .NET images for Debian, Ubuntu chiseled and Azure Linux 3.0 (distroless & NativeAOT). Even an existing NAS such as a Synology DS923+ (Container Manager) or a Raspberry Pi turns into a local label print server with zero extra system packages.
Minimal API sample
ASP.NET Core sample with streaming endpoints, a bounded queue (Channel + BackgroundService) and parallel workers.
Docker & Kubernetes: Dockerfile and Helm chart included
- All tests pass in the official .NET 10 container images for Debian, Ubuntu 24.04 chiseled and Azure Linux 3.0.
Dockerfile.serverincluded: distroless image, runs without root privileges, with a/healthendpoint (or as NativeAOT onruntime-deps:10.0-noble-chiseled-extrafrom ~79 MB).- Helm chart for Kubernetes included. You build the image yourself; it is not published to any public registry.
- No extra system packages required; the NuGet package ships the native libraries.
- Turns an existing NAS (e.g. a Synology DS923+ via Container Manager, ~90 MiB RAM, 295 PNG labels/s with NativeAOT) or a Raspberry Pi right into a local warehouse label print server.
- Asynchronous ASP.NET Core minimal API sample with a job queue (199,000 labels/s ZPL generation on an Apple M3 Max, small 100 × 60 mm label; real shipping label: 200,000+ labels/s).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY . . RUN dotnet publish samples/WebApiDemo -c Release -o /app FROM mcr.microsoft.com/dotnet/aspnet:10.0-azurelinux3.0-distroless WORKDIR /app COPY --from=build /app . ENTRYPOINT ["dotnet","WebApiDemo.dll"]
kind: Deployment spec: replicas: 2 template: spec: containers: - name: label-api resources: requests: { cpu: 500m, memory: 512Mi } limits: { cpu: "1", memory: 1Gi } readinessProbe: httpGet: { path: /health, port: 8080 } --- kind: HorizontalPodAutoscaler spec: metrics: - type: Resource resource: name: cpu target: { type: Utilization, averageUtilization: 70 }
Performance
Fast enough for the 6 p.m. shipping cutoff
Every number is measured, not estimated: release build, .NET 10, Apple M3 Max (10 performance + 4 efficiency cores). The benchmarks ship with the repository.
Real shipping label: 200,000+ labels per second
Measured on 21 Oct 2026 with samples/ShippingBench: Apple M3 Max (10 performance + 4 efficiency cores, 14 in total), macOS 26.6, .NET 10, release build, workstation GC. End to end from the records to a ZPL file on disk, median of 5 runs after one warm-up run. All data sources write a byte-identical 131 MB file (SHA-256 checked), under JIT and NativeAOT alike.
The label
A 100 × 150 mm (4 × 6 in) shipping label with 33 elements: sender block, inverted service box, recipient (name, street, city, country via expression), routing code, weight (0.0 kg), parcels, ship date (Format(ToDate([ShipDate]), "dd.MM.yyyy")), QR code (tracking URL as expression), Code 128 (tracking number), GS1-128 SSCC (18 digits with check digit), page counter, frames and rules. The print job adds a start label (tour, depot, total weight and parcels, first/last SSCC) and an end label (label count, first/last key).
The records vary like real data: names with umlauts and double names, company suffixes of different length, flat numbers, 9 places in AT/DE/CH/IT, 6 services, 2.5–29 kg, 1–7 parcels, valid SSCCs.
Method
- Records → ZPL → file on disk (
StreamWriter, 64 KB buffer), like a print server or batch job. - Byte-identical output across all data sources, checked by SHA-256 before measuring; otherwise the run aborts.
- Same data, same order, same machine; one warm-up run, then the median of 5 runs.
Downloads
- Label: PDF (vector, 1 page, 116 KB) · ZPL (203 dpi, 1.3 KB)
- Print job (start label + 3 shipments + end label): PDF (5 pages, 615 KB) · ZPL (300 dpi, 6.3 KB)
- Image: PNG 203 dpi · PNG 300 dpi
100,000 shipping labels + start and end label into one ZPL file. JIT and NativeAOT build of the same code, median of 5 runs.
| Data source | JIT labels/s | NativeAOT labels/s | 100,000 labels take |
|---|---|---|---|
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 |
For mass data, pass dictionaries or JSON: they stay above 200,000 labels/s under JIT and NativeAOT (slowest single run 208,068/s). C# objects (POCO) reach about 125,000/s under JIT and about 84,000/s under NativeAOT.
5,000 shipping labels rendered with all 14 cores at 203 dpi (one renderer per worker).
| Output | JIT labels/s | NativeAOT labels/s |
|---|---|---|
| PNG preview | 1,224 | 1,182 |
| PCX (1 bit) | 1,058 | 462 |
Container scenario: 5,000,000 records as JSON Lines (1.56 GB, one record per line), read line by line, bound as JsonElement and streamed into a 6.53 GB ZPL file. NativeAOT, same Apple M3 Max; Docker Desktop runs the container in a VM.
| 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 records | 157,489 | 0.64 s | |
| Docker, 100,000 labels, JSON records | 119,271 | 0.84 s | |
| Docker, PNG preview, 14 cores | 1,164 |
Memory stays constant for any number of records: zplCore does not count or buffer a streamed source. Image: 127 MB (runtime-deps:10.0-noble-chiseled-extra, native binary only, non-root). File I/O to the Docker volume is slower than native, hence the lower figures.
Measure it yourself
The benchmark ships with the repository. sample writes the label as PNG, PDF and ZPL; the Docker image is built from build/Dockerfile.shippingbench. Figures depend on your hardware; they are measurements, not a guarantee.
# JIT dotnet run -c Release --project samples/ShippingBench # NativeAOT (same code) dotnet publish samples/ShippingBench -c Release -r osx-arm64 -p:PublishAot=true -o out # Docker (NativeAOT) + 5 million JSON Lines records docker build -f build/Dockerfile.shippingbench -t zplcore-shippingbench . docker run --rm -v zplcore-bench:/data zplcore-shippingbench 1000 10 1 5000000 # label as PNG/PDF/ZPL dotnet run -c Release --project samples/ShippingBench -- sample out/sample
More benchmarks: small 7-element label
The following figures come from samples/PerfTest, samples/RenderBench and samples/WebApiDemo, mostly with a small 100 × 60 mm label (PerfTest: 7 elements, i.e. text, expression, number format, Code 128, counter), not with the shipping label above. Same Apple M3 Max.
Small 7-element label (100 × 60 mm), not the shipping label above. One print job: leading label + 100,000 labels + trailing label, cut only after the last label. 14 cores, samples/PerfTest, single run.
| Scenario | Time | Labels/s | Size | Memory (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) | 3.4 min | 490 | 18.2 MB / 2,000 | 84 MB |
| PNG preview, parallel (14 cores) | 24.8 s | 4,033 | 17.1 MB / 2,000 | 106 MB |
ZPL generation is practically free and streams with constant memory. The stored format halves the data sent to the printer (14.7 instead of 29.1 MB).
ASP.NET Core minimal API from samples/WebApiDemo: 14 cores, server GC, 14 workers, small 100 × 60 mm label (7 elements) at 203 dpi. 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 |
| 10,000 records/request | 112,484 | 36.4 | 76.1 ms | 136.9 ms | 183 MB |
| 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 |
| 100,000 records | 199,102 | 64.6 | 504.3 ms | 505.2 ms | 372 MB |
| Concurrent clients | |||||
| JSON 1k, 1 client | 196,112 | 63.4 | 4.8 ms | 8.6 ms | 404 MB |
| JSON 1k, 4 clients | 240,453 | 77.7 | 16.4 ms | 21.7 ms | 404 MB |
| JSON 1k, 16 clients | 155,344 | 50.2 | 100.8 ms | 120.8 ms | 419 MB |
| DB 10k, 4 clients | 215,699 | 69.7 | 186.2 ms | 202.4 ms | 395 MB |
| DB 10k, 16 clients | 179,860 | 58.2 | 878.0 ms | 907.6 ms | 408 MB |
| Preview (50 labels/request) | |||||
| PDF, 1 client | 583 | 5.0 | 87.0 ms | 94.0 ms | 267 MB |
| PNG (zip), 1 client | 484 | 4.6 | 108.3 ms | 108.7 ms | 269 MB |
| PDF, 14 clients | 5,085 | 43.5 | 131.8 ms | 154.4 ms | 472 MB |
| PNG (zip), 14 clients | 3,995 | 38.1 | 162.0 ms | 188.1 ms | 473 MB |
| Job queue (all jobs submitted at once, latency incl. wait time) | |||||
| 256 × JSON 1k → ZPL (153 jobs/s) | 153,438 | 49.6 | 98.8 ms | 365.5 ms | 885 MB |
| 64 × DB 10k → ZPL (18.6 jobs/s) | 186,361 | 60.3 | 2,236.5 ms | 3,411.1 ms | 828 MB |
| 64 × JSON 50 → PDF (104 jobs/s) | 5,396 | 46.2 | 345.2 ms | 445.6 ms | 467 MB |
ZPL generation saturates at 199,000 labels/s (from 4 parallel requests); more clients only add latency. Rendering costs 40× more and scales with the cores.
Render + PNG
labels/s · factor vs. 1 threadBefore (light bar): 499 → 1,826/s, at most 4.5× at 8 threads.
Text only
labels/s · factor vs. 1 threadPreviously, text got slower with more threads (0.6× at 8 threads) because Skia's glyph cache takes a global lock.
Measured with RenderBench -- 150 scale on the M3 Max. Glyphs are stored once per font and size in a lock-free cache, eliminating SkiaSharp's global locks.
Per label, 100 × 60 mm at 203 dpi. Grayscale or 1-bit PNG instead of RGBA, directly drawn ^GB boxes, lock-free glyph cache.
| Measurement | Before | Now | Improvement |
|---|---|---|---|
| Render to bitmap | 1.28 ms | 0.39 ms | −70% |
| Render + PNG | 8.19 ms | 1.86 ms | −77% |
| PNG size | 18 KB | 9 KB | −50% |
| 100,000 previews, sequential | 14.5 min | 3.4 min | −77% |
| 100,000 previews, parallel | 2.2 min | 25 s | −81% |
| Render + PNG, 10 threads | 1,826/s | 5,022/s | 2.8× |
| Text only, 8 threads | 3,162/s | 42,434/s | 13.4× |
| Workload | Labels | Memory |
|---|---|---|
| Soak test, small labels, parallel | 1,000,000 | RSS 128–138 MB · GC heap 2–3 MB |
| Soak test, 4 × 6″ PNGs, 14 threads | 1,000,000 | RSS 438–481 MB |
| 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 |
Methodology & edge/NAS containers
Release build, .NET 10, Apple M3 Max (10 performance + 4 efficiency cores). Also measured inside a NativeAOT container (runtime-deps:10.0-noble-chiseled-extra, ~79 MB) on a Synology DS923+ NAS (AMD Ryzen R1600, 2 cores / 4 threads): 237,428 labels/s parsing, 1,233 labels/s bitmap rendering and 295 PNG labels/s (> 17,500 PNGs/min). Every measurement can be reproduced with a single command.
# 100,000-label print job dotnet run -c Release --project samples/PerfTest -- 100000 # Thread scaling (label|text|barcode|box|empty) dotnet run -c Release --project samples/RenderBench -- 150 scale

