zstd in .NET 11: ZstandardStream vs Brotli vs GZip

Last updated: November 12, 2026 · Tested with .NET 11.0.0 (SDK 11.0.100) and SDK 10.0.401, Intel Core Ultra 5 125U, Windows 11 25H2 (build 26200)

Short answer: .NET 11 ships Zstandard in the box. System.IO.Compression has ZstandardStream, one-shot ZstandardEncoder/ZstandardDecoder methods, ZstandardCompressionOptions and trained dictionaries, with no NuGet package. HttpClient decodes it with DecompressionMethods.Zstandard, and ASP.NET Core response compression picks zstd by default when the client asks for it. In my tests on JSON and log text, zstd level 1 compressed faster than gzip level 6 and gave smaller output. Decompression was usually the fastest of the four codecs. Brotli level 11 still gave the smallest files, but it compressed at under 1 MB/s.

Everything below comes from code I ran on the .NET 11.0.0 release. The projects, raw output and the script that checks every code block in this post against them are in one folder.

QuestionWhat I found on 11.0.0
Package needed?No. The types are in System.IO.Compression on net11.0
Works on net10.0?No: error CS0246: The type or namespace name 'ZstandardStream' could not be found
LevelsQuality from -131072 to 22, default 3. Optimal = 3, Fastest = 1, SmallestSize = 22
DictionariesYes: ZstandardDictionary.Train + Create. 199-byte JSON messages went from 167 to 47 bytes on average
Multi-frame inputRead correctly by ZstandardStream, TryDecompress and HttpClient
Other zstd tools read the output?Yes. libzstd 1.5.7 and curl 8.15.0 decoded it, and .NET decoded their output
ASP.NET CoreZstandardCompressionProvider is in the default provider list and wins over br and gzip
HttpClientDecompressionMethods.Zstandard (value 8); All sends gzip, deflate, br, zstd

How I Tested It

There are four console projects. They share one Directory.Build.props, which defaults to net11.0 and keeps build output in a local artifacts folder. A small run.sh builds each one with a portable SDK 11.0.100, or with the machine’s SDK 10.0.401 for the net10.0 check. It saves the build errors and program output to results/:

  • ApiDump lists the public Zstandard API in the 11.0.0 shared framework using reflection. The 11.0.0 reference XML files have no docs for the new types, so I didn’t guess any names.
  • Demo runs the API, the levels, dictionaries, multi-frame input, broken input, and a cross-check against a separately built libzstd.
  • Bench is a Stopwatch harness that compares zstd, Brotli, GZip and Deflate on three payloads.
  • Web starts two Kestrel apps in the same process and calls them with HttpClient and curl.

The machine is a laptop with an Intel Core Ultra 5 125U (12 cores, 14 threads). A laptop like this boosts and throttles (run 2 was clearly slower than the other two), so I ran the benchmark three times and show the speed range across the runs. The sizes were identical in every run.

The Zstandard API in .NET 11

Reflection over the 11.0.0 assemblies found these public types (trimmed to the type lines):

# System.IO.Compression 11.0.0+297acf930fb0f5c07fd4e51d78e521b4ad2e5d1f
type System.IO.Compression.ZstandardCompressionOptions : Object
type System.IO.Compression.ZstandardDecoder : Object
type System.IO.Compression.ZstandardDecompressionOptions : Object
type System.IO.Compression.ZstandardDictionary : Object
type System.IO.Compression.ZstandardEncoder : Object
type System.IO.Compression.ZstandardStream : Stream
type System.Net.Http.ZstandardCompressedContent : HttpContent
type Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProvider : Object
type Microsoft.AspNetCore.ResponseCompression.ZstandardCompressionProviderOptions : Object

There’s also a new enum value, DecompressionMethods.Zstandard = 8, and a ZstandardDecompressionProvider for request decompression. Microsoft’s What’s new in .NET 11 libraries page says the types now live in System.IO.Compression, next to GZipStream and BrotliStream. The API reference starts at ZstandardStream. The implementation landed through dotnet/runtime#119575. The native code is inside System.IO.Compression.Native.dll, so there’s no separate libzstd to deploy. That DLL doesn’t export ZSTD_versionString, so I couldn’t read which zstd version it bundles.

Compress and Decompress with ZstandardStream

It works like every other compression stream in .NET. Here are the two helpers the demo uses:

static byte[] Compress(byte[] data, CompressionLevel level)
{
    using var output = new MemoryStream();
    using (var zstd = new ZstandardStream(output, level))
        zstd.Write(data);
    return output.ToArray();
}

static byte[] Decompress(byte[] data)
{
    using var input = new MemoryStream(data);
    using var zstd = new ZstandardStream(input, CompressionMode.Decompress);
    using var output = new MemoryStream();
    zstd.CopyTo(output);
    return output.ToArray();
}

The compressed stream has to be disposed before you read output, because the final block is written on dispose. That’s why the encoder sits in its own using block. On a 1.7 MB JSON API response:

JSON payload: 1,702,638 bytes
compressed: 259,697 bytes, ratio 6.56x, round trip equal: True
frame magic: 28B52FFD

28B52FFD is the standard zstd frame magic number (0xFD2FB528 little-endian), so this is ordinary zstd, not a .NET-specific wrapper.

Compression Levels: What CompressionLevel Means for zstd

zstd levels run much wider than Brotli’s 0 to 11 or zlib’s 0 to 9. To find out what each CompressionLevel value means, I compressed the same JSON at a range of explicit Quality values and compared the bytes:

MinQuality=-131072 MaxQuality=22 DefaultQuality=3
MinWindowLog2=10 MaxWindowLog2=31 DefaultWindowLog2=23
CompressionLevel.Optimal       ->   259,697 bytes, identical to Quality 0,3
CompressionLevel.Fastest       ->   252,343 bytes, identical to Quality 1
CompressionLevel.NoCompression -> 1,702,683 bytes, identical to Quality (none)
CompressionLevel.SmallestSize  ->   201,093 bytes, identical to Quality 22
Quality  -7:   527,193 bytes
Quality  -1:   347,248 bytes
Quality   1:   252,343 bytes
Quality   3:   259,697 bytes
Quality   6:   236,222 bytes
Quality   9:   221,310 bytes
Quality  19:   201,093 bytes
Quality  22:   201,093 bytes

Here’s what that shows:

  • Optimal is zstd’s default level 3. Quality = 0 also means “default”.
  • SmallestSize is level 22. On this 1.7 MB input, 22 gave exactly the same output as 19. Above 19 zstd needs a bigger window to do better, and the input is too small to benefit.
  • Negative levels are zstd’s “fast” mode. They trade ratio for speed.
  • NoCompression stores the data in raw blocks: 45 bytes larger than the input.
  • Level 1 gave smaller output than level 3 on this JSON. It did the same on the log payload below. Higher levels aren’t always smaller at the low end, so measure on your own data.

For the other codecs, the same byte-comparison trick in the Bench project gave the mapping I used in the benchmark:

CompressionLevel.Fastest: Brotli quality 1, GZip level 1
CompressionLevel.Optimal: Brotli quality 4, GZip level 6
CompressionLevel.SmallestSize: Brotli quality 11, GZip level 9

ZstandardCompressionOptions

To go beyond CompressionLevel, pass a ZstandardCompressionOptions. It has Quality, WindowLog2, AppendChecksum, EnableLongDistanceMatching, TargetBlockSize and Dictionary:

var options = new ZstandardCompressionOptions
{
    Quality = 19,
    AppendChecksum = true,
    EnableLongDistanceMatching = true,
    WindowLog2 = 23,
};
using (var output = new MemoryStream())
{
    using (var zstd = new ZstandardStream(output, options, leaveOpen: true))
        zstd.Write(json);
    Console.WriteLine($"Quality 19 + checksum + LDM + 8 MB window: {output.Length:N0} bytes");
}
Quality 19 + checksum + LDM + 8 MB window: 201,097 bytes
Quality = 23 -> ArgumentOutOfRangeException: value ('23') must be less than or equal to '22'. (Parameter 'value')

The checksum costs 4 bytes, and I’d turn it on for anything stored on disk. The broken-input section below shows why. Out-of-range values throw as soon as you set the property, not later during compression.

One-Shot TryCompress and TryDecompress

For buffers you already have in memory, the static methods skip the stream and its MemoryStream copies. They follow the same Try pattern as BrotliEncoder:

byte[] destination = new byte[ZstandardEncoder.GetMaxCompressedLength(json.Length)];
if (ZstandardEncoder.TryCompress(json, destination, out int written, quality: 3, windowLog2: 0))
{
    byte[] frame = destination[..written];
    Console.WriteLine($"TryCompress: {written:N0} bytes (bound was {destination.Length:N0})");

    if (ZstandardDecoder.TryGetMaxDecompressedLength(frame, out long max))
        Console.WriteLine($"TryGetMaxDecompressedLength: {max:N0}");
    byte[] plain = new byte[max];
    bool ok = ZstandardDecoder.TryDecompress(frame, plain, out int plainLength);
TryCompress: 259,700 bytes (bound was 1,709,288)
TryGetMaxDecompressedLength: 1,702,638
TryDecompress: True, 1,702,638 bytes, equal: True
TryDecompress into a 1,000-byte buffer: False, bytesWritten 0
TryGetMaxDecompressedLength on ZstandardStream output: True, 1,703,936

The one-shot encoder writes the original size into the frame header, so TryGetMaxDecompressedLength returns the exact size. Stream output doesn’t record it, and you get a slightly larger upper bound instead. If the destination is too small, TryDecompress returns false and doesn’t throw. The demo passes windowLog2: 0, and the result was within 3 bytes of the stream output at the same level.

There’s also an instance API for pipelines and custom buffers: new ZstandardEncoder(quality) with Compress(source, destination, out consumed, out written, isFinalBlock) returning an OperationStatus, plus Flush and Reset. Feeding the 1.7 MB JSON in 128 KB slices through a 64 KB output buffer took 13 calls and produced byte-for-byte the same 259,697 bytes as the stream. If Span<T>-style APIs are new to you, the C# Span tutorial covers the pattern.

Dictionary Compression for Small Messages

Small payloads are where every general-purpose compressor struggles. A 200-byte JSON event doesn’t contain enough repetition to compress well. zstd fixes this with a dictionary trained on sample messages. Both the encoder and the decoder load the dictionary, so the repeated field names never have to go over the wire. I trained one on 2,000 sample events and tested it on 500 different ones generated with another seed:

byte[][] training = Payloads.SmallJsonMessages(2000, seed: 1);
byte[][] messages = Payloads.SmallJsonMessages(500, seed: 2);
byte[] samples = training.SelectMany(m => m).ToArray();
int[] lengths = training.Select(m => m.Length).ToArray();
byte[] dictionaryBytes = ZstandardDictionary.Train(samples, lengths, maxDictionarySize: 16 * 1024);
using ZstandardDictionary dictionary = ZstandardDictionary.Create(dictionaryBytes, quality: 3);

Train returns the raw dictionary as a byte[]. That’s what you store and version, and Create loads it with the compression level to use. In RC3, Train returned a ZstandardDictionary directly, so RC-era code fails on 11.0.0:

Program.cs(108,40): error CS0029: Cannot implicitly convert type 'byte[]' to 'System.IO.Compression.ZstandardDictionary'

To compress with it, call ZstandardEncoder.TryCompress(data, dest, out n, dict, windowLog2: 0). To decompress, call ZstandardDecoder.TryDecompress(c, back, out int n, dictionary), or use the ZstandardStream constructor that takes a dictionary:

trained dictionary: 16,384 bytes from 2000 samples (400,972 bytes)
500 messages, avg 199 bytes each
  gzip (optimal):     avg  172 bytes  ratio 1.16x
  zstd q3, no dict:   avg  167 bytes  ratio 1.19x
  zstd q3 + dict:     avg   47 bytes  ratio 4.24x
TryDecompress without the dictionary: False
ZstandardStream without the dictionary -> InvalidDataException: The provided dictionary is not valid for decoding the current data.

That’s a 3.5 times smaller result than plain zstd or gzip on the same messages. It’s the most useful zstd feature you couldn’t get from the built-in codecs before. Use it for cache entries, queue messages, or anything you store by the million. The catch: the dictionary is part of your data format now. Version it, and keep every version you’ve ever used for as long as data compressed with it exists. Without the dictionary the frame is unreadable. TryDecompress just returns false, and the stream throws.

Benchmark: zstd vs Brotli vs GZip vs Deflate

Each codec goes through its Stream API, writing into a reused MemoryStream, because that’s how most application code uses them. For each payload, codec and level, the harness warms up for 300 ms. It then times 7 runs of at least 200 ms each and takes the median time per operation:

static double Median(Action op)
{
    var sw = Stopwatch.StartNew();
    while (sw.ElapsedMilliseconds < 300) op();
    var runs = new double[7];
    for (int r = 0; r < runs.Length; r++)
    {
        int n = 0;
        sw.Restart();
        do { op(); n++; } while (sw.ElapsedMilliseconds < 200);
        runs[r] = sw.Elapsed.TotalSeconds / n;
    }
    Array.Sort(runs);
    return runs[runs.Length / 2];
}

MB/s is always measured against the uncompressed size. Every output was decompressed and compared with the input before timing. The three payloads:

  • JSON API response: 4,000 orders serialized with System.Text.Json (1,702,638 bytes). The data is generated with a fixed seed: customers, addresses, order lines, prices and a random tracking ID.
  • ASP.NET Core log: 15,000 console-format log lines (3,522,402 bytes). Request-finished lines, warnings, and SQL timeout exceptions with stack traces, each with a random trace ID.
  • nupkg: the real BenchmarkDotNet 0.15.8 package (1,480,253 bytes). It’s a zip file, so the content is already compressed.

Speeds are the min to max across three full runs. Higher is better for ratio and MB/s.

JSON API response (1,702,638 bytes)

Codec / levelSizeRatioCompress MB/sDecompress MB/s
zstd 1 (Fastest)252,3436.75299-566444-1702
zstd 3 (Optimal)259,6976.56126-407411-1345
zstd 9221,3107.6954-70807-1547
zstd 19201,0938.471.7-2.5881-1370
brotli 1 (Fastest)280,3576.07238-379289-414
brotli 4 (Optimal)244,9776.9588-95533-717
brotli 11 (SmallestSize)191,0218.910.6536-602
gzip 1 (Fastest)404,4264.21371-392809-913
gzip 6 (Optimal)257,9016.6084-1141019-1084
gzip 9 (SmallestSize)252,1296.7545-59998-1131
deflate 6 (Optimal)257,8836.6076-107955-1181

ASP.NET Core log (3,522,402 bytes)

Codec / levelSizeRatioCompress MB/sDecompress MB/s
zstd 1 (Fastest)559,7986.29426-5521423-1708
zstd 3 (Optimal)593,6185.93219-317730-1215
zstd 9533,5156.6048-66737-1237
zstd 19470,3557.491.7-2.41320-1591
brotli 1 (Fastest)593,9335.93337-441411-473
brotli 4 (Optimal)543,4386.4877-109454-715
brotli 11 (SmallestSize)449,4127.840.5-0.6382-550
gzip 1 (Fastest)902,3323.90359-369824-880
gzip 6 (Optimal)568,8716.1996-104877-957
gzip 9 (SmallestSize)602,6745.8452-59856-932
deflate 6 (Optimal)568,8536.1997-119883-1001

nupkg, already compressed (1,480,253 bytes)

Codec / levelSizeRatioCompress MB/sDecompress MB/s
zstd 1 (Fastest)1,477,3671.001127-12828232-11634
zstd 3 (Optimal)1,418,5391.04683-8282115-2687
zstd 191,402,0861.065.5-7.41945-2433
brotli 1 (Fastest)1,477,3181.001327-18301281-1534
brotli 4 (Optimal)1,415,6531.0595-150262-301
brotli 11 (SmallestSize)1,394,9401.060.3-0.4162-170
gzip 1 (Fastest)1,551,7700.9599-101420-433
gzip 6 (Optimal)1,470,6361.0143-451217-1266

What I take from these numbers:

  • zstd 1 is the one to beat on text. On the JSON it was smaller than gzip 6 (252,343 vs 257,901 bytes) and compressed 3.5 to 5 times faster. On the logs it was also smaller (559,798 vs 568,871 bytes) and 4.4 to 5.3 times faster.
  • Decompression is zstd’s strongest point. zstd 1 decoded 3.5 to 3.9 times faster than Brotli 1 on the logs and 1.5 to 4.1 times faster on the JSON. Level 19 still decoded at 0.9 to 1.6 GB/s. In this laptop’s slowest run, zstd 1 and 3 decoded the JSON at only 444 and 411 MB/s, below gzip 6 (1,042) and even Brotli 4 (717), so treat “faster” as usually, not always.
  • Brotli 11 still makes the smallest files (8.91x vs zstd 19’s 8.47x on JSON), but it compressed at 0.5 to 0.6 MB/s. That only makes sense for static assets you compress once at build time.
  • Brotli 4 (Optimal) vs zstd 9: zstd 9 was smaller on both text payloads and decoded faster in every run (1.1 to 2.9 times), but compressed slower (48 to 70 MB/s against 77 to 109).
  • Don’t compress zip files. Nothing got the nupkg below 94% of its size. gzip 1 made it 5% bigger and was the slowest of the fast levels at 99 to 101 MB/s. zstd 1 and Brotli 1 at least went through at over 1 GB/s.
  • gzip 9 lost to gzip 6 on the log (602,674 vs 568,871 bytes). Higher isn’t always smaller there either.
  • Managed allocations were tiny for every codec: 120 to 312 bytes per operation (one zstd 19 run measured 456), measured with GC.GetAllocatedBytesForCurrentThread. The encoder and decoder state lives in native memory, which this number doesn’t include.

Interop: Do Other zstd Tools Read .NET’s Output?

The zstd command-line tool isn’t installed on this machine, so I couldn’t test the CLI itself. Two independent zstd builds were available, though. Git for Windows ships libzstd.dll 1.5.7 (in mingw64\bin) and a curl 8.15.0 built with zstd/1.5.7. The demo loads that libzstd with NativeLibrary.Load and checks in both directions:

Git libzstd version: 1.5.7
libzstd ZSTD_decompress(.NET stream output): 1,702,638 bytes, equal: True
libzstd ZSTD_decompress(.NET two frames): "first frame. second frame."
libzstd level 19 frame (201,096 bytes) -> ZstandardStream: 1,702,638 bytes, equal: True
two libzstd frames -> ZstandardStream: "first frame. second frame."
two libzstd frames -> ZstandardDecoder.TryDecompress: True, "first frame. second frame."

curl decoding HTTP responses from Kestrel is in the ASP.NET Core section below. It works there too.

Multi-Frame zstd Streams (dotnet/runtime#129047)

A zstd stream can be several complete frames back to back. Encoders that flush per buffer produce this, and so do CDNs and files joined with cat. During the .NET 11 previews, ZstandardStream stopped after the first frame and silently dropped the rest. dotnet/runtime#129047, “Fix ZstandardStream truncating multi-frame zstd responses”, fixed that and was merged in July for preview 7. I built two-frame inputs myself and tested them on 11.0.0:

two frames: 44 bytes
ZstandardStream reads: "first frame. second frame."
ZstandardDecoder.TryDecompress: True, "first frame. second frame."
two 1,702,638-byte stream frames: decompressed 3,405,276 bytes (expected 3,405,276)
same via CopyToAsync: 3,405,276 bytes
frame + 7 junk bytes: no exception, 13 bytes

Sync and async reads both handle it, and so does HttpClient (shown below). The last line is worth knowing about: bytes after the final frame that aren’t a zstd frame are ignored without an error. Don’t rely on the decoder to catch a corrupted tail.

ASP.NET Core Response Compression with zstd

The response compression middleware has a ZstandardCompressionProvider with EncodingName = zstd and SupportsFlush = True. I started two apps. One calls plain services.AddResponseCompression(). The other lists the providers explicitly and sets zstd to level 6:

    services.AddResponseCompression(options =>
    {
        options.Providers.Add<ZstandardCompressionProvider>();
        options.Providers.Add<BrotliCompressionProvider>();
        options.Providers.Add<GzipCompressionProvider>();
    });
    services.Configure<ZstandardCompressionProviderOptions>(options =>
        options.CompressionOptions = new ZstandardCompressionOptions { Quality = 6 });

Both apps serve the same 1.7 MB JSON from /orders. An HttpClient with no automatic decompression sends each Accept-Encoding value and reads the raw Content-Encoding header and body size:

defaults Accept-Encoding: zstd             -> Content-Encoding: zstd    259,697 bytes on the wire
defaults Accept-Encoding: br               -> Content-Encoding: br      280,357 bytes on the wire
defaults Accept-Encoding: gzip             -> Content-Encoding: gzip    404,426 bytes on the wire
defaults Accept-Encoding: gzip, br, zstd   -> Content-Encoding: zstd    259,697 bytes on the wire
defaults Accept-Encoding: zstd;q=0.5, br   -> Content-Encoding: br      280,357 bytes on the wire
explicit Accept-Encoding: zstd             -> Content-Encoding: zstd    236,222 bytes on the wire
explicit Accept-Encoding: gzip, br, zstd   -> Content-Encoding: zstd    236,222 bytes on the wire

Three things to note:

  • zstd is in the default provider list, ahead of Brotli and gzip. With no configuration at all, a client that offers gzip, br, zstd gets zstd, even though zstd comes last in its header. q-values are still respected. The response compression docs list the defaults as Brotli, Gzip and Zstandard for ASP.NET Core 11. After upgrading, any client that offers zstd will start receiving zstd instead of Brotli. That includes your own services.
  • The default levels differ per provider. The byte counts match the benchmark exactly: default zstd is level 3 (259,697), default Brotli is Fastest, quality 1 (280,357), and default gzip is Fastest, level 1 (404,426). new ZstandardCompressionProviderOptions() reports Quality 0, which means “zstd default”. Out of the box, zstd responses were 7% smaller than Brotli’s and 36% smaller than gzip’s.
  • Level 6 cost little. Raising zstd to 6 cut the response to 236,222 bytes. For dynamic responses I’d stay at 6 or lower. The benchmark shows level 9 already drops compression speed to 48 to 70 MB/s.

I tested over plain HTTP. Over HTTPS, the middleware compresses nothing until you set EnableForHttps (the docs explain the BREACH risk). If you also cache responses, the output caching guide covers how the two middlewares fit together.

curl, decoding with its own libzstd, read the explicit app’s responses correctly:

curl --compressed -H "Accept-Encoding: zstd": Content-Encoding: zstd; 200 application/json size_download=236222; decoded file equals JSON: True
curl --compressed -H "Accept-Encoding: br": Content-Encoding: br; 200 application/json size_download=280357; decoded file equals JSON: True

HttpClient Automatic zstd Decompression

The enum value is DecompressionMethods.Zstandard, and DecompressionMethods.All includes it. I called the same endpoint with each setting. A second endpoint echoes back the Accept-Encoding header the client actually sent:

    var handler = new SocketsHttpHandler { AutomaticDecompression = methods };
    using var client = new HttpClient(handler);
AutomaticDecompression = None           sends Accept-Encoding: (none)                 gets 1,702,638 bytes, Content-Encoding header after: (removed/none), JSON intact: True
AutomaticDecompression = Zstandard      sends Accept-Encoding: zstd                   gets 1,702,638 bytes, Content-Encoding header after: (removed/none), JSON intact: True
AutomaticDecompression = All            sends Accept-Encoding: gzip, deflate, br, zstd gets 1,702,638 bytes, Content-Encoding header after: (removed/none), JSON intact: True

The default (None) sends no Accept-Encoding at all, so the server doesn’t compress, and you pay for the full 1.7 MB on the wire. That’s the same as in earlier .NET versions. If you configure clients through IHttpClientFactory, set AutomaticDecompression in ConfigurePrimaryHttpMessageHandler, as shown in the HttpClient and IHttpClientFactory guide. Once the handler decodes a response, it removes the Content-Encoding header. To see the header, send Accept-Encoding yourself with decompression off, as in the previous section.

For the multi-frame case from #129047, an endpoint returned two zstd frames back to back with Content-Encoding: zstd. Both HttpClient and curl decoded the whole thing:

HttpClient decoded 3,405,276 bytes (expected 3,405,276), both copies intact: True
curl /two-frames: size_download=519400; decoded 3,405,276 bytes

Compressing request bodies

ZstandardCompressedContent wraps any HttpContent and sets Content-Encoding: zstd. On the server, AddRequestDecompression() already includes zstd: its default providers are zstd, br, deflate, gzip.

    using var content = new ZstandardCompressedContent(new ByteArrayContent(json), CompressionLevel.Fastest);
    content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
    using var response = await client.PostAsync($"{urlExplicit}/upload", content);
request Content-Encoding: zstd
server says: received 1,702,638 decompressed bytes, JSON intact: True

Only compress request bodies when you control the server. A server without request decompression would receive the compressed bytes as the body.

Errors and Gotchas I Hit

Targeting net10.0. The same Demo project built with SDK 10.0.401 fails on the first Zstandard type:

Program.cs(46,27): error CS0246: The type or namespace name 'ZstandardStream' could not be found (are you missing a using directive or an assembly reference?)
Program.cs(64,31): error CS0103: The name 'ZstandardEncoder' does not exist in the current context

There’s no official package that backports these types. On .NET 10 and earlier you still need a third-party library such as ZstdSharp or ZstdNet. If you multi-target, put zstd code behind #if NET11_0_OR_GREATER. For the rest of the move, see upgrading to .NET 11: breaking changes.

Broken input. Here’s what the decoder does with bad data:

truncated (last 10 bytes cut) -> InvalidDataException: Found truncated data while decoding.
not zstd (gzip bytes) -> InvalidDataException: Decoder encountered invalid data.
one byte flipped mid-stream: no exception, 1,702,638 bytes
  ...output equals the original: False
checksum byte flipped -> InvalidDataException: Decoder encountered invalid data.
empty input: no exception, 0 bytes

The third and fourth lines are the important ones. AppendChecksum is off by default, and without it a corrupted byte in the data came back as wrong output with no exception. With the checksum on, the same kind of damage throws. Empty input also decodes to nothing without complaint.

Large windows throw IOException, not InvalidDataException. The decoder accepts windows up to 227 (128 MB) by default. A frame written with WindowLog2 = 28 fails even when the data itself is only 3.5 MB:

WindowLog2=27 frame, default decoder: no exception, 3,522,402 bytes
WindowLog2=28 frame, default decoder -> IOException: Data requires too much memory for decoding. Consider increasing the 'maxWindowLog2' parameter.

This limit is a safety feature: it stops a hostile stream from making you allocate gigabytes. If you accept zstd from outside, keep the limit or lower it with new ZstandardDecompressionOptions { MaxWindowLog2 = 20 }. If you decode your own --long archives, raise it. Catch IOException as well as InvalidDataException.

Smaller ones: invalid Quality throws ArgumentOutOfRangeException from the property setter. TryDecompress returns false for both “buffer too small” and “wrong dictionary”, so check TryGetMaxDecompressedLength first if you need to tell them apart. And as with every compression stream, dispose the encoder before you read the output.

Which One Should You Use?

  • HTTP APIs on .NET 11: leave the defaults. Clients that offer zstd get it at level 3, and everyone else falls back to Brotli or gzip. Make sure your own HttpClients set AutomaticDecompression.
  • Logs, exports, backups, anything both written and read by your code: zstd 1 to 3 for speed, or 9 for size, with AppendChecksum = true.
  • Many small messages (cache, queues, events): a trained ZstandardDictionary, versioned.
  • Static web assets compressed once: Brotli 11 is still the smallest.
  • Interop with tools that only speak gzip: gzip, at level 6 rather than 9.
  • Already-compressed data (zip, images, video): don’t compress it.

What I Didn’t Test

  • The zstd CLI, because it isn’t installed here. I used libzstd 1.5.7 and curl 8.15.0 instead.
  • Browsers. All HTTP tests used HttpClient and curl over plain HTTP.
  • Linux, macOS and Arm64, and NativeAOT builds.
  • Server throughput under load with response compression on. The benchmark measures single-threaded codec speed.
  • Third-party libraries (ZstdSharp, ZstdNet), so I can’t say how they compare in speed.

Related: What’s new in .NET 11 libraries: Zstandard (Learn) · DecompressionMethods (Learn) · dotnet/runtime#119575 · dotnet/runtime#129047

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *