Compared with the other C# SDK
There is one other public C# SDK for TCGdex: TCGdex
by luizaraujodev (source),
MIT licensed, published 2026-03-02, targeting net10.0.
This page exists because performance claims are cheap and measurements are not. This SDK is slower at every cold operation measured here, allocates less on all but one, and is an order of magnitude faster on a warm cache hit.
This page was wrong until 2026-08-07, in its own disfavour. It claimed caching was off on both sides. The other SDK caches by default, and the harness asked for the same card on every iteration, so its numbers were warm cache hits against this SDK's full fetch. Everything below is re-measured with that fixed. The correction is written up rather than quietly applied — see The fairness rule that was not true.
Method
Both clients accept an injected HttpClient, which is what makes an honest
comparison possible: without it the only option is measuring over the live API,
which reports TCGdex's servers and the local connection rather than either
library.
Rules the harness holds itself to:
Same stub transport, same recorded payload on both sides.
Caching off on both, which now means
CacheTTL = 0on theirs rather than an assumption. Pitting a warm cache against a cold fetch measures a configuration difference and calls it speed.Losses reported alongside wins.
Reproducible. Every benchmark class in this repository now has an arm for each SDK, not just the card fetch:
dotnet run -c Release --project TcgDex.CSharpSdk.Benchmarks -- --filter "*Comparison*" dotnet run -c Release --project TcgDex.CSharpSdk.Benchmarks -- --filter "*LargePayload*" dotnet run -c Release --project TcgDex.CSharpSdk.Benchmarks -- --filter "*Eviction*" dotnet run -c Release --project TcgDex.CSharpSdk.Benchmarks -- --filter "*CachingBenchmarks*"
The fairness rule that was not true
The second rule sat on this page for weeks as a claim rather than a check. The
other SDK's client arrives with a MemoryTCGDexCache and CacheTTL = 3600
already set, so from the second iteration onward it was answering from memory
while this SDK went through its whole transport. Counting requests at the
handler settles it: three calls, one request.
CacheTTL = 0 is what disables it. Assigning Cache = null throws
ArgumentNullException.
The distortion was modest — their cache stores the response string, not the deserialized model, so a hit re-parses and returns a different instance each time. It was saving the stub transport, not the deserialization that dominates both sides. Modest is not the point. A stated fairness rule nobody verified is worth less than no rule, because it reads as evidence.
The other package is referenced by the benchmark project only, never by the SDK, so it reaches no consumer.
Results
All four workloads, both SDKs, caching genuinely off. Bold marks the winner.
Fetching and deserializing one card (2,938 bytes):
| This SDK | This SDK, DeserializePricing = false |
TCGdex |
|
|---|---|---|---|
| Time | 24.79 µs | 20.93 µs | 18.57 µs |
| Allocated | 18.38 KB | 16.26 KB | 25.12 KB |
Fetching and deserializing the unpaginated card list (2,356,046 bytes, ~21,000 entries):
| This SDK | TCGdex |
|
|---|---|---|
| Time | 24.86 ms | 20.83 ms |
| Allocated | 10.88 MB | 17.77 MB |
Building a filtered, sorted, paginated query:
| This SDK | TCGdex |
|
|---|---|---|
| Time | 2,741 ns | 98 ns |
| Allocated | 4,664 B | 416 B |
A warm cache hit, all the way to a Card:
| This SDK | TCGdex |
|
|---|---|---|
| Time | 1.40 µs | 15.29 µs |
| Allocated | 2.12 KB | 12.22 KB |
The one decisive win, and it is architectural rather than incremental. Both SDKs
cache the response body — theirs a decoded string, ours bytes — so before this
layer existed both re-parsed on every hit and this row read 25.71 µs against
15.29 µs. Retaining the deserialized model against its ETag makes it 10.9×
the other way. See caching.md.
Storing into a response cache, replacing an existing entry:
| This SDK | TCGdex |
|
|---|---|---|
| Time | 96.6 ns | 47.4 ns |
| Allocated | 48 B | 0 B |
So: slower on every cold timing, by 1.13–1.34× on a fetch, 1.19× on the list, 2× on a cache store and 28× on query building. Lighter on allocations everywhere except query building, by 27% on a card and 39% on the list. And 10.9× faster on a warm cache hit, which is the shape most applications actually run in.
The allocation reversal is the part that changed. This page previously reported 0.66× — this SDK allocating half again as much as theirs — and that was their warm cache skipping a transport round trip.
Two caveats on the cache row, both of which matter more than 49 ns. Their cache
has no bound: MemoryTCGDexCache is a Dictionary behind a lock with no
MaxEntries and no eviction, so a long-lived process retains every response
body it has ever fetched, as a UTF-16 string at roughly twice the bytes of the
payload. And it is on by default, which is how this page came to be wrong.
Ours is bounded at 512 entries and stores bytes — the 48 B and the extra 49 ns
are what a bound costs.
What the first measurement changed
The fetch row started worse — 29.1 µs and 43.3 KB — and the benchmark is
what found out why. Three fixes to BoundedContent, none of which gave anything
up:
| Change | Allocated |
|---|---|
| Original | 43.3 KB |
Deserialize from UTF-8 bytes rather than a decoded string |
34.6 KB |
Pre-size the buffer from Content-Length |
34.6 KB |
Rent the 16 KB read chunk from ArrayPool instead of allocating it |
18.6 KB |
57% of the allocations removed, and the size limit, the AOT safety and every test are unchanged — 447 unit tests across three frameworks and 149 live integration tests still pass.
Two things worth keeping from that:
- The largest cost was scratch space, not the payload. A fresh 16 KB chunk buffer per request was bigger than the 2,938-byte body it was reading. Renting it was a four-line change and did more than the other two together.
- Pre-sizing the buffer changed nothing at all, despite being the obvious
fix and the one attempted second — at this payload size. On the 2.3 MB list
it saves 2.24 MB per request, because that is where a
MemoryStream's doubling growth starts to cost. The row above is not wrong, it is size-specific, and reading it as a general result was the mistake. Seemeasuring.md.
Correction: the models are the same size
An earlier version of this page said their CardModel exposes 37 properties to
this SDK's 22, and concluded they deserialize more. Both numbers were wrong.
Their file declares five classes, so the 37 summed CardModel with four nested
model types; the 22 missed eight properties on this side that use a
backing-field pattern and span two lines.
Counted properly it is about 30 each. Model size explains nothing, in either direction.
One field does, though, and it is the one that is absent. Their CardModel
has no Pricing property — and there is no pricing type anywhere in their
assembly. The block arrives on the wire and is discarded. On this side it is the
single most expensive part of a card to parse, at 3.86 µs and 2.12 KB of a
24.79 µs, 18.38 KB fetch.
That is why the results table carries a DeserializePricing = false column. It
is not there to flatter the ratio — parsing pricing is a feature, and a consumer
who wants prices from the other SDK writes that code themselves. It is there
because a reader comparing deserialization speed should be able to see the two
numbers side by side and decide which one answers their question.
Not AOT either
Their ModelBase.Fill resolves each property with
GetType().GetProperty(ToPascalCase(name)) — reflection, per property, per
object. That is not trim- or AOT-safe, and per-property reflection is normally
slower than source generation.
At one card they are ahead despite that. At 21,000 objects it starts to show: on the 2.3 MB list their advantage narrows to 1.19× on time and they allocate 63% more than this SDK does. A fixed cost per property per object is cheap until there are enough objects.
Where the difference actually is
Two fields, and it is a design choice rather than an optimisation:
// Theirs
public JsonElement? DamageJson { get; set; }
public JsonElement? LevelJson { get; set; }
Those are the polymorphic fields, stored raw. attacks[].damage really is
polymorphic in the live API — xy1-1 returns the number 60, swsh1-1 returns
the string "50+" because printed damage can carry a modifier — and level
behaves the same way. Their model keeps the JsonElement and hands the problem
to the caller.
This SDK converts instead. FlexibleStringConverter normalises both shapes to
string? so attack.Damage is always usable, and Attack.BaseDamage parses the
leading digits to int? for numeric comparison.
TcgPlayerPricingConverter does the equivalent for pricing, whose printing keys
vary by card — normal and reverse-holofoil on swsh3-136, holofoil on
base1-4 — collecting unrecognised keys into a dictionary so an unanticipated
printing is not silently dropped.
So part of the measured gap is work that has not been avoided, only moved.
To get from their DamageJson to a number a consumer writes roughly what the
converter does:
int? baseDamage = card.Attacks?[0].DamageJson switch
{
{ ValueKind: JsonValueKind.Number } e => e.GetInt32(),
{ ValueKind: JsonValueKind.String } e => ParseLeadingDigits(e.GetString()),
_ => null,
};
— per call site, per field, in every application, untested. The same applies to pricing, where the caller has to enumerate properties and skip the two metadata keys themselves.
This is an explanation, not a defence. Two things follow from it and only one is comfortable:
- The comparison is not measuring identical work, and this page should say so rather than presenting the ratio bare. Work done once in a library beats the same work repeated in every consumer, and it does not show up on their side of the table at all.
- It still does not account for the whole gap. The gap is now 6.2 µs rather than
the 10 µs this paragraph was written against, and
TcgPlayerPricingConverteris measurably 3.86 µs of it — so the converters explain most of what is left, with source generation losing to reflection covering the rest. Being slower is not excused by being more convenient.
Reading the query result
Deliberately not equivalent work. This SDK translates a LINQ expression tree, which the compiler checks:
new CardQuery().Where(c => c.Hp > 100)
Theirs takes field names and values as strings, which nothing can check:
Query.Create().GreaterThan("hp", 100)
A typo in "hp" is a runtime surprise in one and a compile error in the other.
So 2.7 µs against 98 ns is the price of the type-safe form, not a race to
concatenate strings — and it is charged once per request, against a network
round trip of 20–50 ms. It is 0.01% of a request. Real, measured, and irrelevant
to throughput.
The honest summary of that row: this SDK trades ~2.6 µs per query for compile-time checking. Whether that is worth it is the reader's call, but the number should not be hidden.
Reading the fetch result
The copying identified by the first run has been fixed — see above. What remains of the gap is mostly deliberate:
- Source generation is not the faster path here. Measured separately in
measuring.md: the source-generated path is 1.23× the time and 1.5× the allocations of reflection for these models. It stays, because it is what makes the SDK trim- and AOT-safe. That is a trade this SDK makes on purpose and the other one does not. - Custom converters.
FlexibleStringConverterhandles the polymorphicdamagefield andTcgPlayerPricingConverterthe dynamic printing keys — work the other SDK does not do, in exchange for typed access to fields whose shape varies.
Neither is a reason to stop looking, and a second correction is due here: an earlier version of this page called it "a ~10 KB payload". The card fixture is 2,938 bytes. Allocating 18.6 KB to handle it is 6.4× the payload, which is not defensible — the wrong figure made it look better than it is.
Where the deserialization time goes
Isolated by stripping one block from the same card, at full precision (StdDev ~0.3 µs):
| Deserializing the full card | Time | Allocated |
|---|---|---|
| As shipped | 23.04 µs | 11.17 KB |
With the pricing block removed |
18.35 µs | 8.97 KB |
| Reflection, same model | 17.26 µs | 7.40 KB |
TcgPlayerPricingConverter accounts for 4.7 µs and 2.2 KB — 20% of both.
It is hand-written code, which makes it the first place to look rather than the
last, and it was paid on every card whether or not the caller read pricing.
TcgDexOptions.DeserializePricing = false now makes that optional, worth
3.86 µs and 2.12 KB end to end. Slightly less than the 4.7 µs above, because
that row deleted the block from the JSON while the option only skips building
from it — Utf8JsonReader.Skip still walks the tokens. The API offers no way to
close that last gap: fields=, select=, pricing=false and include= all
return the identical 2,940 bytes from the live service.
It defaults to on. A null pricing must go on meaning "the API sent none"
rather than "it was switched off", and 4 µs against a 20–50 ms round trip does
not buy a silently wrong answer.
The remaining ~5.8 µs between source generation and reflection is System.Text.Json internals. That one stays: source generation is what makes the SDK trim- and AOT-safe, and no amount of it being slower here changes that.
Deserialization is roughly 86% of the whole request path — the transport, the logging, the activity and the URI construction together account for about 3.5 µs. Anything spent optimising elsewhere is spent in the wrong place.
What this comparison does not cover
- Network time, which dominates real usage and is identical for both.
- Feature coverage — a different question. This SDK multi-targets
netstandard2.0, so it runs on Unity and .NET Framework where anet10.0-only package cannot; it has ETag revalidation,IAsyncEnumerablepagination,ILogger/ActivitySourceobservability, and verified Native AOT support. None of that makes it faster at the two things measured above. - Correctness, which the test suite covers — 456 unit tests across three frameworks and a mutation score around 88%.
- Anything the other SDK does not have. ETag revalidation, a bounded cache,
netstandard2.0,IAsyncEnumerablepagination, Native AOT — there is no row to lose because there is nothing to compare against.
Being slower at deserialization is still a fair criticism of this SDK, and it is the one to make: every cold timing row above is a loss. What is no longer fair is the allocation claim, which this page had backwards, nor the suggestion that the SDK is careless with memory — it allocates 27% less on a card and 39% less on the list. And on the path a long-running application spends most of its time in, a warm cache hit, it is 10.9× faster.