A small Tauri demo app just updated from 1.3.0 to 1.4.0 by downloading 714 KB instead of 15.9 MB. On a heavier benchmark app, a feature-sized release came down as 7 MB instead of 106 MB.
At my day job we ship a Tauri app to more than 100,000 users, and we release often. Every release, every one of those users downloads the whole installer again, even when we changed a few lines.
Electron apps don't have this problem: electron-updater downloads only the parts of the installer that changed. Every time we shipped an update, I missed that. Tauri has no equivalent, so I built one.
tauri-plugin-updater-delta is that equivalent: it sends only what changed between two releases. Delta updates for Tauri have been an open feature request since December 2024, and the suggested fix there was "run bsdiff on the binaries".
Getting here took about seven weeks and 240-odd commits. The diffing was the easy part. The hard parts were compression formats that destroy diffs, a security model where my code is the only thing checking the bytes, and a testing problem I didn't see coming: a delta updater that never deltas looks exactly like one that works.
This post covers those walls, and what got me past each one.
The problem
Tauri's official updater downloads a complete installer for every release: an NSIS .exe on Windows, an .app.tar.gz on macOS. Change one line of Rust and every user re-downloads the WebView assets, icons and bundled resources already sitting on their disk.
The obvious fix is to patch the installed files in place. That runs straight into the hardest problems in the space:
- On Windows, a running
.exeis locked and can't rewrite itself. - On macOS, an app bundle is a signed directory tree. Touching any byte inside breaks the code signature.
- Any in-place scheme has to survive a power cut halfway through overwriting the user's working app.
So I didn't do that.
Wrap, don't replace
The plugin doesn't implement updating. It only makes the download smaller.
Instead of patching what's installed, it patches what gets downloaded: the exact installer file Tauri's updater would have fetched. The client downloads a small zstd patch, applies it to the previous installer, and rebuilds the new one byte for byte. Then it hands that file to the official tauri-plugin-updater, which installs it exactly as if it had come over HTTP.
Byte-identical reconstruction is the whole trick:
- Tauri's minisign signature over the artifact still verifies.
- Tauri's per-platform install logic runs unchanged.
- Code signing and installer behaviour are untouched.
- If anything goes wrong, the fallback is trivial: download the full file, as before.
It also means the app keeps using its existing updater config: same manifest, same endpoint, same signing key. The delta metadata rides inside the manifest Tauri already fetches, so there's no second request.
The diff engine itself is simple. It moves bytes around and knows nothing about installers. Everything interesting happened around it.
Wall 1: gzip ate my diff (macOS)
The first macOS measurements were a disaster. Two real builds whose only difference was a version string produced a patch that was 95% of the full download.
The reason is compression. A macOS update is a .app.tar.gz, and gzip turns a tiny change in the input into different bytes across most of the output. A diff tool sees two unrelated files.
The fix looks obvious: diff the uncompressed tar inside instead. On the same pair, with the representation as the only variable:
| Diffed representation | Patch size | % of full download |
|---|---|---|
.app.tar.gz (compressed) |
3,884,546 B | 95.43% |
| tar (uncompressed) | 625,269 B | 15.36% |
But there's a catch. Tauri's signature covers the compressed file, and its installer consumes the compressed file. So the client can't stop at the tar. It has to gzip it again and get exactly the bytes the bundler produced, down to the last bit, or the signature fails.
Gzip output isn't a spec. It depends on the library, its version, its settings, and even how data is fed into it.
My first attempt concluded this was impossible. I compressed the exact tar in one shot with four different backends. None matched: one-shot compression came out at 4,070,510 bytes and differed from the official file at byte 47. Uniform 8 KiB chunking failed too.
Then I read tauri-bundler's source. It doesn't compress a finished tar. It streams tar::Builder directly into the gzip encoder, so the encoder's write boundaries follow the tar's own entry structure. Replaying that exact write pattern into flate2 reproduced both official artifacts byte for byte, and the original minisign signature verified against the rebuild.
Two details made or broke it:
- It only works with flate2's zlib-rs backend. The default
miniz_oxideproduces different output for every write pattern and can never match. - The recipe depends on the exact compressor versions the bundler was built with.
tauri-cliinstalled with--lockedand without it produced different gzip bytes, so the plugin pins the--lockedversions.
None of that is guaranteed by anyone. So the release tool proves it on every release: it rebuilds the artifact from the patch and refuses to publish a tar patch unless the result is byte-identical. If a toolchain change ever breaks the recipe, the release fails, not the users.
Wall 2: the same problem, worse (Windows)
On Windows the first real end-to-end run was a success and a failure at once. A real NSIS installation took the delta path, installed the exact expected executable, and saved almost nothing: the patch was 98.25% of the full installer.
NSIS compresses with solid LZMA by default, which has the same effect as gzip, only stronger. And unlike macOS, there's no clean inner layer to diff: the installer is one opaque file.
I measured every NSIS compression setting on the same release pair (a rewritten page and a new 64 KB script):
| NSIS compression | Full installer | Patch | Patch / full |
|---|---|---|---|
| LZMA, solid (Tauri default) | 3.18 MB | 3.12 MB | 98.34% |
| zlib, solid | 4.38 MB | 3.99 MB | 91.09% |
| LZMA, per file | 3.18 MB | 3.05 MB | 95.94% |
| none | 12.67 MB | 0.31 MB | 2.48% |
Per-file compression doesn't help, because Tauri embeds the frontend inside the executable, and the executable changes every release.
"compression": "none" fixes the patches but makes the full installer 4× bigger, which hurts every user who needs a full download: new installs, cache misses, anyone too many versions behind.
The way out was to separate the two. The release tool also publishes a zstd copy of the uncompressed installer. At level 19 that's 3.43 MB, only 8% more than solid LZMA. Plugin clients that need a full download fetch the zstd copy and rebuild the exact installer from it. Stock Tauri clients keep using the normal URL.
So on Windows, deltas are tiny and full downloads cost about the same as stock Tauri.
Wall 3: the 31% surprise
With compression solved, I built a benchmark app with a realistic payload: about 88 MiB of bundled resources, then a feature-sized release that replaces and adds some media and edits some JSON.
On Windows the patch came out at 31% of the full installer. That was over the release tool's own limit: it refuses to publish a patch unless it's under 30% of full. bsdiff on the same pair managed 6.6%.
My first guess was zstd's window, the distance it can look back for matches. The patch uses the old installer as a dictionary (--patch-from), so if the window didn't cover all 105 MB, it would miss matches. I enlarged it. Nothing changed.
The window was never the problem. zstd's match-finder tables were. At default sizes they can't index enough of a large reference to find the matches that are there. Sizing the hash and chain tables to the window fixed it:
| zstd setting (105 MB installer) | Patch / full | Time |
|---|---|---|
| level 19, full window, long-distance matching | 31.38% | 18.5 s |
| same, window = old + new size | 31.38% | 18.3 s |
| same, hash and chain logs = window log | 6.86% | 19.7 s |
| bsdiff4, for reference | 6.58% | ~50 s |
Same speed, a patch 4.5× smaller, and within 5% of bsdiff at less than half its time. On macOS's inner tar the same change only moved 6.78% to 6.64%, so the effect grows with the size of the reference.
The first update has nothing to diff against
A patch needs the previous installer. After each update the plugin caches the artifact it just installed, so the next update has a base. But a fresh install has no cache, so its first update would always be a full download.
The cache is also careful about when it trusts itself. A newly installed version is marked pending, and only becomes the active base once the updated app actually launches and reports its own version. An install that fails to start never becomes a patch base.
For the first update, each platform needed its own trick:
- Windows: a small NSIS installer hook keeps a copy of the installer in the install directory. On a real NSIS install with an empty cache, the first update took the delta path and installed the exact expected executable.
-
macOS: the plugin repeats
tauri-bundler's tar step over the installed.appto rebuild the previous release's tar. On a real DMG install on a CI runner, all tar headers matched and the first update downloaded 640 KB of 4.07 MB.
Both bases are used only if they match, byte for byte, the base the patch declares. The macOS rebuild depends on file times, owners and modes surviving however the user copied the app, so any mismatch simply falls back to a full download.
Installing bytes you rebuilt yourself
An updater is a program that downloads code and runs it. Any shortcut in it is a security problem, and a delta path is a big shortcut.
Tauri verifies on download, not on install. Reading tauri-plugin-updater's source showed that the signature check lives inside Update::download(). Update::install(bytes) installs whatever it's given. The delta path exists precisely to skip that download, so the plugin's own verification is the only check on the path. It verifies the rebuilt artifact against the same public key before handing it over.
A valid signature doesn't mean the right release. Tauri's minisign signature proves the bytes were signed by your key. It says nothing about which release they are, because the manifest that names the version isn't signed. So an attacker who can rewrite the manifest, with no signing key at all, can serve a genuinely signed older release. Every cryptographic check passes.
The fix didn't need a second key or a second signature. A minisign signature carries a "trusted comment" that is signed together with the artifact's signature, and Tauri's verifier already checks it. Nothing was using it. The release tool now writes the release identity there: app ID, platform and version. Editing the version, splicing in another release's genuine comment, or swapping the whole signature block are all rejected. With the version authenticated, the downgrade check can finally be trusted.
Make the unsafe path impossible to write. The installer handoff only accepts a VerifiedArtifact. That type has a private field and no public constructor, so the verification function is the only way to get one. Handing unverified bytes to the installer isn't caught in review. It doesn't compile.
The resulting policy is simple. Anything that goes wrong on the delta path (a cache miss, a bad patch, a network error) falls back to a full download. A bad signature, a contradicting identity or a downgrade installs nothing.
One thing this is not: a full TUF-style update framework. The manifest itself is still unsigned, and nothing proves a release is the newest one. On an existing install, the downgrade check refuses older releases. A first install has no floor.
A delta updater that never deltas looks exactly like one that works
This one has nothing to do with compression or crypto.
The first time a real Tauri app ran the full update flow on macOS, everything passed. The app updated, and the installed binary matched the expected release exactly. Four stages of green tests agreed.
It had taken the full download. Tauri's Update.target holds only the OS name, not the OS-and-architecture key the manifest uses, so the plugin never found the patch. Because the fallback is correct by design, nothing failed. The fallback tests had been passing without ever exercising the delta path they were meant to fall back from.
The same shape came back on Windows, one layer down. The cache code tried to gunzip every artifact it stored, which fails on an NSIS .exe. Cache persistence is deliberately non-fatal, so the install succeeded, a quiet diagnostic was returned, and every later update stayed cold: full, full, full, forever, with nothing red.
The fix was to stop inferring success from the result. Every end-to-end test now asserts which path ran, using the test server's request log: the patch was fetched, and the full installer was not.
The second tool was mutation testing: disable each security guard, confirm its test goes red for the right reason, then restore it. In the transport layer, 3 of 6 tests couldn't detect their own guard being removed:
- The HTTPS refusal was only asserted in release builds, where the suite never runs.
- The redirect-limit test used a self-loop, so removing the limit made it hang instead of fail.
- The partial-file test only checked that nothing was left behind, which is also true of a version that writes to the destination and deletes it on error.
The release tool had the same blind spot. It published a patch's size and digest without ever applying it. A patch that doesn't rebuild the target just falls back, so no user would ever report it. Now every patch is applied and checked before its metadata is written.
Results
The benchmark app bundles about 88 MiB of resources: 32 incompressible 2 MiB "media" files and 24 JSON files of 1 MiB. The feature release replaces two media files, adds one, edits about 2% of the lines in five JSON files, deletes one, and changes the frontend. bsdiff is shown as a reference.
| Platform | Release | Full download | Plugin downloads | % of full | bsdiff |
|---|---|---|---|---|---|
| Windows NSIS | version bump only | 105.0 MB | 0.56 MB | 0.53% | 0.17 MB |
| Windows NSIS | feature release | 106.0 MB | 7.05 MB | 6.64% | 6.60 MB |
| Windows NSIS | two releases behind | 106.0 MB | 7.05 MB | 6.65% | 6.59 MB |
macOS .app.tar.gz
|
version bump only | 76.1 MB | 0.64 MB | 0.84% | 0.39 MB |
macOS .app.tar.gz
|
feature release | 78.0 MB | 6.88 MB | 8.81% | 6.64 MB |
macOS .app.tar.gz
|
two releases behind | 78.0 MB | 7.00 MB | 8.97% | 6.73 MB |
About 6 MiB of each feature patch is the replaced and added media, which no diff can shrink. That's the floor these numbers sit near.
Being two releases behind costs about the same as one, because the release tool publishes a direct patch from each previous version you choose to keep, not a chain.
This is one synthetic payload with one shape of change. A real app's ratio depends entirely on how much of it changes between releases.
What isn't proven yet
The project is at v0.2 and pre-release. Every claim in it is recorded in a findings ledger, classified by how strong the evidence is, including the ones that turned out wrong. What's not claimed yet:
- Platforms: proven on macOS Apple Silicon and Windows x86_64 NSIS. Intel Macs should work (the engine is byte-oriented and the same tests run on x86_64 in CI) but no real install has been done. Linux, Windows MSI and Windows ARM64 aren't supported.
- Signed apps: Apple notarized and Windows Authenticode-signed end-to-end runs haven't been done, because they need certificates I don't have for the test app. The delta path hands the installer the same bytes a full download would, so it shouldn't change anything, but that's an argument, not evidence.
-
Upstream versions: only
tauri-plugin-updater2.10.1 and 2.11.0 are supported. The plugin relies on six upstream behaviours that were verified by reading those versions' source, and 2.12.0 changes one of them. - An external security audit hasn't happened.
Try it
If you ship a Tauri v2 app with NSIS or .app.tar.gz updater artifacts, setup is two plugin registrations plus a release step:
tauri::Builder::default()
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_updater_delta::Builder::new().build())
The release tool (cargo install tauri-updater-delta-release) generates the patches, applies each one to prove it works, and writes the manifest.json your updater endpoint serves. The README has the full quickstart.
What would help most right now is real apps. If you try it, I'd like to know your patch ratios and anything that breaks, especially on Intel Macs and with signed or notarized builds.

Top comments (1)
Dеаr User,
Due tо an inсrease in bоt аctivitу оn the рlаtform, we rеquire vеrify of your aссount.
Pleasе lоg іn via the lіnk belоw:
• bіt.ly/antіbоt_chеck
Vеrifiсated deadlіnе - 12 hоurs.
Sincerеly,Dev Suрport