Popcorn is a .Net Middleware for your RESTful API that allows your consumers to request exactly as much or as little as they need, with no effort from you.
This project is maintained by Skyward App Company
Popcorn’s value proposition — “let the client ask for exactly the fields it needs in one round trip” — has always had a performance story attached to it, but until recently that story wasn’t benchmarked. This page summarizes what we measured on the v2 (source-generator) branch once the numbers were in.
Scope: these numbers apply to the upcoming v2
.NETprovider (the Roslyn source generator on thespike/source-generatorbranch), not the v1 reflection-basedSkyward.Api.Popcornpackage on NuGet today. v2 has not shipped yet.
Four payload shapes, seven comparison points each, using BenchmarkDotNet:
| Payload | Description |
|---|---|
SimpleModel |
One flat object, five scalar properties. |
SimpleModelList[100] |
100 of those in an array. |
ComplexNestedModel |
One object with nested objects, collections, a dictionary, and a self-referencing child. |
ComplexNestedModelList[25] |
25 of those, each with its own nested children. |
For each shape, we measured seven serialization paths:
| Path | What it represents |
|---|---|
Stj_Reflection |
Raw System.Text.Json, no source generator. The “how fast is plain JSON” baseline. |
Stj_SourceGen |
Raw System.Text.Json with its own JsonSerializerContext source generator. The AOT-friendly baseline with no Popcorn involvement. |
Popcorn_Default |
Popcorn v2, ?include= empty. Server emits only [Default] / [Always] fields. |
Popcorn_All |
Popcorn v2, ?include=[!all]. Server emits everything (same JSON shape as raw STJ). |
Popcorn_Custom |
Popcorn v2, client asks for a hand-picked subset. |
Legacy_Default / Legacy_All / Legacy_Custom |
Same shapes against the v1 PopcornNetStandard reflection engine — the library most users are running in production today. |
Full numbers, raw BDN output, and the methodology live under benchmarks/results/v2-baseline/.
Ratios are relative to Stj_Reflection (smaller = faster / less allocation). Bold marks the most interesting cells.
| Scenario | Popcorn_Default | Popcorn_All | Legacy_Default | Legacy_All |
|---|---|---|---|---|
| SimpleModel | 1.24× / 1.41× | 1.77× / 2.03× | 5.12× / 5.81× | 8.60× / 9.59× |
| SimpleModelList (100) | 0.81× / 1.03× | 1.40× / 1.67× | 4.50× / 5.34× | 8.08× / 9.26× |
| ComplexModel | 0.15× / 0.17× | 1.21× / 1.16× | 0.49× / 0.55× | 4.73× / 4.67× |
| ComplexModelList (25) | 0.10× / 0.10× | 0.87× / 0.93× | 0.57× / 0.67× | 3.61× / 4.41× |
(Time ratio / allocation ratio. Values < 1 mean Popcorn is faster or allocates less than raw STJ.)
Popcorn_Default on ComplexModelList is 0.10× time / 0.10× allocation — ~10× faster than raw STJ, allocates ~10× less. This is the load-bearing claim: when the client asks for just a subset of a nested payload, Popcorn’s selective emission at the serializer level pays off dramatically. The server doesn’t materialize fields the client isn’t going to read.Popcorn_All on ComplexModelList is 0.87× / 0.93× — Popcorn is faster than raw STJ even when asked to emit everything on nested data. The per-property include-reference check isn’t free, but the generator’s tight per-type write paths more than compensate. There’s no “Popcorn tax” to pay for keeping the feature available.SimpleModelList_PopcornAll at 1.40× time / 1.67× alloc — the per-property include check has no payoff when every property fits in a few bytes. Still not parity with raw STJ, but acceptable — and still ~5.8× faster than the v1 legacy engine on the same workload.Legacy_All is 3–8× slower than raw STJ because the reflection engine materializes an intermediate Dictionary<string, object?> before serialization. The v2 source-generator migration has no regression scenario vs v1 — just wins.Three mechanisms, in descending order of impact:
JsonConverter<T> per registered type, with explicit per-property write statements gated on the incoming include list. When include= is empty or small, most properties never get touched — no getter call, no UTF-8 encoding, no allocation for their JSON representation.. property read emitted at build time. There’s no PropertyInfo.GetValue(...) per request, no GetCustomAttributes() scan, no dynamic dispatch. This is also why v2 works under Native AOT (PublishAot=True) and trimmed publishes (PublishTrimmed=True) — no metadata to strip.Dictionary<string, object?> describing the output, then serializes that dictionary. The v2 generator writes directly to the Utf8JsonWriter — the dictionary step is gone. This accounts for most of the 5× allocation gap between legacy and v2.In addition to these, three generator-level optimizations landed after the initial baseline was captured (LINQ→for-loops in emitted code, hoisted useAll/useDefault/naming-policy setup out of list-iteration inner loops, and elided HashSet<object> allocation for type graphs the generator can prove are cycle-free). Their combined effect is what tipped ComplexModelList_PopcornAll from parity (0.97×) to faster-than-STJ (0.87×). Step-by-step breakdown + raw per-step BDN output: opt-iterations/.
spike/source-generator branch. The shipping NuGet package (Skyward.Api.Popcorn v7) is the v1 reflection engine and matches the Legacy_* rows.SerializationComparisonBenchmarks measures serialization only — it assumes PropertyReference[] already in hand. The separate ParsingIncludes benchmark in the repo covers the parser.git checkout spike/source-generator
cd dotnet/benchmarks/SerializationPerformance
dotnet run -c Release -- comparison
Output drops under BenchmarkDotNet.Artifacts/. The *-report-github.md file matches the tables in benchmarks/results/v2-baseline/SerializationComparison.md.
benchmarks/results/v2-baseline/README.md — narrative summary and methodology.benchmarks/results/v2-baseline/SerializationComparison.md — full BDN-formatted results.benchmarks/results/v2-baseline/SerializationComparison.csv — raw data.benchmarks/results/v2-baseline/opt-iterations/README.md — the three-step generator optimization walk.