Summary
Add zstd compression and Compression Dictionary Transport (CDT, RFC 9842) support to the Static Web Assets SDK. This enables zstd as a publish-time compression format alongside gzip and brotli, and introduces delta compression for returning users by using previous versions of assets as zstd dictionaries.
Motivation
Blazor WebAssembly and other SPA applications ship significant amounts of static content (framework assemblies, JS bundles, CSS). Currently, the SDK compresses these with gzip and brotli at publish time. Two key limitations exist:
- No zstd support: Zstd is now supported by all major browsers and offers a better speed/ratio tradeoff than gzip. More importantly, zstd supports external dictionaries, which enables CDT.
- No delta updates: Every time users revisit an updated application, they re-download the full compressed content even if only small portions changed. For Blazor WASM apps with ~4MB of framework DLLs, a minor app update still requires re-downloading the entire payload.
Compression Dictionary Transport (RFC 9842) solves the second problem. When a user has previously visited a site, the browser can use the cached version of a resource as a dictionary for decompressing the updated version, dramatically reducing transfer sizes for subsequent visits.
Goals
- Add zstd as a publish-time compression format for static web assets, producing
.zst files alongside .gz and .br
- Generalize the compression pipeline so that C# tasks are format-agnostic and driven by
CompressionFormat MSBuild item metadata (making it straightforward to add future formats)
- Implement CDT support that produces
.dcz (dictionary-compressed zstd) files when a previous build's asset pack is provided
- Generate an asset pack (
.zip) at publish time containing the manifest and all assets, to be used as dictionary input for future builds
- Emit correct CDT endpoint metadata per RFC 9842 so that ASP.NET Core's content encoding negotiation can serve delta-compressed responses
- Keep dictionary support orthogonal from compression — the
UsesDictionary metadata on CompressionFormat items controls whether a format participates in CDT, independent of the compression logic itself
Non-goals
- Runtime zstd decompression support — this is an ASP.NET Core runtime concern (tracked separately)
- Automatic dictionary storage/retrieval — the user provides the previous pack path; CI pipeline integration is out of scope
- Build-time compression — zstd is publish-only (like brotli max quality); build-time continues to use gzip for dev server performance
- Custom user-defined compression formats — the pipeline is extensible for SDK-defined formats, not arbitrary user plugins
- N→N+2 or multi-version dictionary chains — CDT uses only the immediately previous version as dictionary
Scenarios
Scenario 1: Zstd compression at publish time
A developer publishes a Blazor WASM app and wants zstd-compressed assets served alongside gzip and brotli.
No code changes needed — zstd is automatically included in the publish compression pipeline via the CompressionFormat MSBuild items. The publish output includes .zst files for all static web assets. ASP.NET Core's content negotiation serves them to browsers that send Accept-Encoding: zstd.
Scenario 2: CDT delta compression for returning users
A developer ships updates to a Blazor WASM app and wants returning users to download only the differences, not full files.
<PropertyGroup>
<StaticWebAssetDictionaryCompression>true</StaticWebAssetDictionaryCompression>
<StaticWebAssetPreviousAssetPack>$(ArtifactsPath)\previous\staticwebassets.pack.zip</StaticWebAssetPreviousAssetPack>
</PropertyGroup>
At publish:
- The SDK reads the previous asset pack and matches current assets to previous versions by their non-fingerprinted route patterns
- For each changed asset, the SDK compresses the current version using the previous version as a zstd dictionary, producing
.dcz files
- The SDK generates endpoint metadata with:
Use-As-Dictionary response header on all content-negotiated responses (identity, gzip, br, zstd) so browsers cache resources for future dictionary use
Dictionary-Hash endpoint property containing the SHA-256 of the dictionary (:base64: per RFC 9842 §2.2 Structured Fields Byte Sequence format)
Content-Encoding: dcb selector on delta-compressed endpoints
Vary: Accept-Encoding, Available-Dictionary for correct caching
- A new asset pack is generated for the current version at
$(StaticWebAssetPublishAssetPackPath) (default: $(OutputPath)staticwebassets.pack.zip)
The CI pipeline saves the asset pack between builds. On the next publish, the saved pack is provided as StaticWebAssetPreviousAssetPack.
Scenario 3: ASP.NET Core routing integration
At request time, ASP.NET Core's ContentEncodingNegotiationMatcherPolicy:
- Reads the
Dictionary-Hash endpoint property from the manifest
- Compares it against the
Available-Dictionary request header sent by the browser
- If the hash matches, serves the
.dcz response; otherwise falls back to br/gzip/zstd/identity
This ensures delta-compressed responses are only served when the browser has the correct dictionary cached.
Detailed design
Generalized compression framework
The compression pipeline was refactored from hardcoded gzip/brotli logic to a generic, metadata-driven model:
CompressionFormat MSBuild items: Each format declares FileExtension, ContentEncoding, and optionally UsesDictionary metadata
ApplyCompressionNegotiation: Refactored to use EndpointGroup<CompressionGroupState> for route-based grouping. Quality ranking, selector generation, and header emission are all driven by format metadata, not format names
ResolveCompressedAssets: Parses the Formats property and resolves format metadata generically — no hardcoded format switching
New MSBuild tasks
| Task |
Purpose |
ZstdCompress |
ToolTask that invokes the SWA CLI tool for zstd compression with optional dictionary support |
GeneratePublishAssetPack |
Creates a ZIP containing the publish manifest and all assets, keyed by assets/{BasePath}/{RelativePath} |
ResolveDictionaryCandidates |
Opens a previous asset pack, matches current assets to previous versions by route pattern, outputs dictionary candidate items |
Asset pack format
staticwebassets.pack.zip
├── manifest.json # Standard StaticWebAssetsManifest
└── assets/
├── _content/MyApp/js/app.js
├── _content/MyApp/css/site.css
└── ... # All publish assets by BasePath/RelativePath
Route-based dictionary matching
Dictionary candidates are matched by non-fingerprinted route patterns, not by file paths. This ensures stable matching across versions even when fingerprints change:
- For each current asset, compute a
MatchPattern from the endpoint route (e.g., site.*.js for site.{fingerprint}.js)
- Scan the previous pack's manifest for assets whose endpoints match the same pattern
- If a match is found and the content differs, output the previous asset as a dictionary candidate
CDT endpoint metadata (per RFC 9842)
For assets with dictionary candidates, the negotiation task emits:
{
"Route": "/app.js",
"Selectors": [
{ "Name": "Content-Encoding", "Value": "dcb", "Quality": "0.9" }
],
"EndpointProperties": [
{ "Name": "Dictionary-Hash", "Value": ":pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:" }
],
"ResponseHeaders": [
{ "Name": "Vary", "Value": "Accept-Encoding, Available-Dictionary" }
]
}
Additionally, Use-As-Dictionary response headers are added to all content-negotiated responses for each asset so browsers build up dictionary caches over time.
MSBuild properties
| Property |
Default |
Description |
StaticWebAssetDictionaryCompression |
false |
Enable CDT support and the dcz compression format |
StaticWebAssetPreviousAssetPack |
(empty) |
Path to the previous version's asset pack ZIP |
StaticWebAssetPublishAssetPackPath |
$(OutputPath)staticwebassets.pack.zip |
Output path for the current version's asset pack |
CompressionFormat items (defined in targets)
<CompressionFormat Include="gzip">
<FileExtension>.gz</FileExtension>
<ContentEncoding>gzip</ContentEncoding>
</CompressionFormat>
<CompressionFormat Include="brotli">
<FileExtension>.br</FileExtension>
<ContentEncoding>br</ContentEncoding>
</CompressionFormat>
<CompressionFormat Include="zstd">
<FileExtension>.zst</FileExtension>
<ContentEncoding>zstd</ContentEncoding>
</CompressionFormat>
<CompressionFormat Include="dcz" Condition="'$(StaticWebAssetDictionaryCompression)' == 'true'">
<FileExtension>.dcz</FileExtension>
<ContentEncoding>dcb</ContentEncoding>
<UsesDictionary>true</UsesDictionary>
</CompressionFormat>
Performance results
| Scenario |
vs Brotli |
Notes |
| Standalone zstd |
+7.9% larger |
Expected — zstd trades size for CDT capability |
| CDT delta (app update) |
-21.7% smaller |
Clear win for update payloads |
| Best individual file |
-98.6% smaller |
System.Collections: 8,674 → 118 bytes |
For a typical Blazor WASM app update (adding a feature page):
- Without CDT: 624 KB re-download (brotli)
- With CDT: 489 KB re-download (dcz) — 135 KB savings
Testing
- Unit tests for all new tasks (
ZstdCompress, GeneratePublishAssetPack, ResolveDictionaryCandidates)
- Unit tests for generalized
ApplyCompressionNegotiation with CDT headers
- Unit tests for
ComputeMatchPattern literal preservation
- Integration tests for zstd compression in build and publish pipelines
- Integration test:
Publish_WithPreviousPack_GeneratesDczForModifiedAssets — end-to-end CDT flow
- Baseline tests updated for new
.zst/.dcz format registration across all app types (Blazor WASM, standalone, etc.)
Open items
Summary
Add zstd compression and Compression Dictionary Transport (CDT, RFC 9842) support to the Static Web Assets SDK. This enables zstd as a publish-time compression format alongside gzip and brotli, and introduces delta compression for returning users by using previous versions of assets as zstd dictionaries.
Motivation
Blazor WebAssembly and other SPA applications ship significant amounts of static content (framework assemblies, JS bundles, CSS). Currently, the SDK compresses these with gzip and brotli at publish time. Two key limitations exist:
Compression Dictionary Transport (RFC 9842) solves the second problem. When a user has previously visited a site, the browser can use the cached version of a resource as a dictionary for decompressing the updated version, dramatically reducing transfer sizes for subsequent visits.
Goals
.zstfiles alongside.gzand.brCompressionFormatMSBuild item metadata (making it straightforward to add future formats).dcz(dictionary-compressed zstd) files when a previous build's asset pack is provided.zip) at publish time containing the manifest and all assets, to be used as dictionary input for future buildsUsesDictionarymetadata onCompressionFormatitems controls whether a format participates in CDT, independent of the compression logic itselfNon-goals
Scenarios
Scenario 1: Zstd compression at publish time
A developer publishes a Blazor WASM app and wants zstd-compressed assets served alongside gzip and brotli.
No code changes needed — zstd is automatically included in the publish compression pipeline via the
CompressionFormatMSBuild items. The publish output includes.zstfiles for all static web assets. ASP.NET Core's content negotiation serves them to browsers that sendAccept-Encoding: zstd.Scenario 2: CDT delta compression for returning users
A developer ships updates to a Blazor WASM app and wants returning users to download only the differences, not full files.
At publish:
.dczfilesUse-As-Dictionaryresponse header on all content-negotiated responses (identity, gzip, br, zstd) so browsers cache resources for future dictionary useDictionary-Hashendpoint property containing the SHA-256 of the dictionary (:base64:per RFC 9842 §2.2 Structured Fields Byte Sequence format)Content-Encoding: dcbselector on delta-compressed endpointsVary: Accept-Encoding, Available-Dictionaryfor correct caching$(StaticWebAssetPublishAssetPackPath)(default:$(OutputPath)staticwebassets.pack.zip)The CI pipeline saves the asset pack between builds. On the next publish, the saved pack is provided as
StaticWebAssetPreviousAssetPack.Scenario 3: ASP.NET Core routing integration
At request time, ASP.NET Core's
ContentEncodingNegotiationMatcherPolicy:Dictionary-Hashendpoint property from the manifestAvailable-Dictionaryrequest header sent by the browser.dczresponse; otherwise falls back tobr/gzip/zstd/identityThis ensures delta-compressed responses are only served when the browser has the correct dictionary cached.
Detailed design
Generalized compression framework
The compression pipeline was refactored from hardcoded gzip/brotli logic to a generic, metadata-driven model:
CompressionFormatMSBuild items: Each format declaresFileExtension,ContentEncoding, and optionallyUsesDictionarymetadataApplyCompressionNegotiation: Refactored to useEndpointGroup<CompressionGroupState>for route-based grouping. Quality ranking, selector generation, and header emission are all driven by format metadata, not format namesResolveCompressedAssets: Parses theFormatsproperty and resolves format metadata generically — no hardcoded format switchingNew MSBuild tasks
ZstdCompressToolTaskthat invokes the SWA CLI tool for zstd compression with optional dictionary supportGeneratePublishAssetPackassets/{BasePath}/{RelativePath}ResolveDictionaryCandidatesAsset pack format
Route-based dictionary matching
Dictionary candidates are matched by non-fingerprinted route patterns, not by file paths. This ensures stable matching across versions even when fingerprints change:
MatchPatternfrom the endpoint route (e.g.,site.*.jsforsite.{fingerprint}.js)CDT endpoint metadata (per RFC 9842)
For assets with dictionary candidates, the negotiation task emits:
{ "Route": "/app.js", "Selectors": [ { "Name": "Content-Encoding", "Value": "dcb", "Quality": "0.9" } ], "EndpointProperties": [ { "Name": "Dictionary-Hash", "Value": ":pZGm1Av0IEBKARczz7exkNYsZb8LzaMrV7J32a2fFG4=:" } ], "ResponseHeaders": [ { "Name": "Vary", "Value": "Accept-Encoding, Available-Dictionary" } ] }Additionally,
Use-As-Dictionaryresponse headers are added to all content-negotiated responses for each asset so browsers build up dictionary caches over time.MSBuild properties
StaticWebAssetDictionaryCompressionfalsedczcompression formatStaticWebAssetPreviousAssetPackStaticWebAssetPublishAssetPackPath$(OutputPath)staticwebassets.pack.zipCompressionFormat items (defined in targets)
Performance results
For a typical Blazor WASM app update (adding a feature page):
Testing
ZstdCompress,GeneratePublishAssetPack,ResolveDictionaryCandidates)ApplyCompressionNegotiationwith CDT headersComputeMatchPatternliteral preservationPublish_WithPreviousPack_GeneratesDczForModifiedAssets— end-to-end CDT flow.zst/.dczformat registration across all app types (Blazor WASM, standalone, etc.)Open items
.dczfile to support multiple dictionaries per asset — needs format designStaticWebAssetDictionaryCompression=truebut pack file is missing/invalidCompressionFormatInfo,PreviousRouteMatch) to replace tuples in task internals