Performance Recommendations
Configuration practices to reduce bundle size, improve startup time, and optimize binary performance in Tauri v2 with React and Vite
A Tauri application ships two distinct payloads: a Rust binary that runs the native backend, and a frontend bundle that renders the UI inside a system WebView. Both can balloon in size if left unoptimized, and both directly affect how quickly your app starts and how much disk space it consumes. The good news is that most of the heavy lifting can be done through configuration files—no deep profiling required just to get a lean baseline.
Optimizing the Rust Binary
The Rust backend compiles into a single native executable. By default, Cargo’s release profile balances compile time against binary size, but a few targeted tweaks in Cargo.toml can shrink the binary dramatically and make runtime code slightly faster. These settings live in src-tauri/Cargo.toml.
Release Profile Configuration
The profile named [profile.release] controls how Cargo optimizes the final binary when you run tauri build. The defaults are conservative; overriding them lets you trade a longer compile time for a smaller, sometimes faster output.
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true
opt-level = "z" instructs the compiler to optimize for size aggressively. It will apply many of the same speed optimizations as opt-level = "s" but will also shrink the binary further by favoring smaller code sequences. The compile time increases, but the resulting executable often shaves off tens of megabytes.
lto = "fat" enables link-time optimization across the entire crate graph. This means the compiler can inline functions across crate boundaries and discard code that is never reached, which is particularly effective when you use only a subset of a dependency’s API. "fat" performs a single optimization pass over the entire program; it uses more memory during the build but produces the tightest result.
codegen-units = 1 forces the compiler to produce a single translation unit for code generation. The default (16 or 256) speeds up parallel compilation, but more units mean the optimizer sees less context. Reducing to one unit lets LTO and inlining see the whole picture, further reducing size.
panic = "abort" removes the unwinding machinery from the binary. In a desktop application, a panic will still produce an error message in the console, but the thread will abort immediately instead of unwinding the stack. This cuts out the formatting and unwinding code paths, which adds up across many potential panic sites.
strip = true removes debug symbols from the binary. In a release build, those symbols are rarely needed and can account for a substantial portion of the file size. On macOS and Windows, note that code-signing happens after stripping; Tauri’s bundler handles the sequence correctly, but if you run custom signing steps, ensure they occur after the strip.
Never Ship a Debug Binary:
Running cargo build without --release compiles an unoptimized binary with full debug information. The resulting executable can be 5-10 times larger and runs significantly slower. Always use tauri build (which builds in release mode) for any binary you intend to distribute.
Strip and Code Signing on macOS:
Removing symbols with strip = true is safe, but if you use a custom signing script, make sure it runs after Tauri’s bundler has finished stripping. Signing before stripping invalidates the signature. The built-in Tauri bundler manages this automatically.
Exclude Unused Tauri Commands
Every #[tauri::command] you register in lib.rs adds a small amount of metadata and dispatch code. Tauri can automatically remove commands that are never invoked from the frontend if you enable removeUnusedCommands in the build configuration.
{
"build": {
"removeUnusedCommands": true
}
}
This setting runs a static analysis pass during the build that detects which commands are actually called (via invoke in the frontend) and prunes the rest from the final binary. It works best when you call Tauri commands explicitly by name; dynamic invoke calls with computed strings may prevent the analysis from proving a command is used, so those commands will be kept conservatively.
Selective Plugin Usage
Tauri’s plugin system is designed to be explicit. Every plugin you add in Cargo.toml and register in lib.rs gets compiled in, even if you only need one function from it. Audit your dependencies before building for release. If you are using tauri-plugin-fs only to read a single config file, consider whether the lighter tauri-plugin-fs with a scoped permission is sufficient, or if you can use a simpler approach with a custom command that calls std::fs directly (subject to your capability configuration). Fewer plugins mean fewer dependencies, which translates directly to a smaller binary.
Verifying Binary Size Reduction:
After applying the release profile changes, run tauri build and check the output binary size. On a fresh Tauri v2 + React project, the optimized binary is noticeably smaller than the default release build. You can use tools like cargo bloat to see the size contribution of each crate and identify remaining outliers.
Reducing the Frontend Bundle Size
The frontend bundle is served from the frontendDist directory (typically ../dist when using Vite). A large JavaScript or CSS bundle increases startup time because the WebView must parse and execute all that code before the first meaningful paint. Vite’s production build already applies tree shaking and minification, but you can tighten it further.
Vite Build Configuration
Configure Vite to split vendor chunks, suppress source maps in production, and target modern browsers. The following vite.config.ts adds sensible defaults for a Tauri project.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
target: 'esnext',
sourcemap: false,
minify: 'esbuild',
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
});
target: 'esnext' skips transpilation steps for older browsers. Since Tauri uses the platform’s native WebView (Chromium-based on Windows, WebKit on macOS/Linux), all supported environments handle modern JavaScript natively. This reduces the amount of polyfill and transform code in the bundle.
sourcemap: false prevents separate .map files from being generated and removes inline source maps from the bundles. During development source maps are invaluable, but in production they add bulk and expose source code structure.
minify: 'esbuild' (the default in Vite) uses esbuild for fast minification. It is already production-grade; the alternative 'terser' can produce slightly smaller output at the cost of a much slower build, but esbuild’s output is typically within a few percent and far quicker.
manualChunks splits react and react-dom into a separate vendor chunk. This chunk changes far less frequently than application code, so users updating your app via the auto-updater will only download the changed app chunk, not the entire bundle.
Code Splitting with Dynamic Imports
For a React app, route-based code splitting ensures that only the code for the current screen is loaded. React’s lazy and Suspense work with Vite’s dynamic import support out of the box.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
const HomePage = lazy(() => import('./pages/HomePage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>Loading…</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
export default App;
When Vite encounters the dynamic import() call, it creates a separate chunk for that module. The chunk is only fetched when the route is first visited. This keeps the initial JavaScript payload small, reducing the time until the first screen appears.
Avoid Inlining Large Assets in the Bundle:
Images, fonts, and JSON blobs that are imported directly into JavaScript end up in the bundle as Base64 or data URLs. This inflates the JavaScript chunk and delays parsing. Instead, place large assets in the public/ directory (or a static assets folder outside the module graph) and reference them by URL. Vite will copy the public/ directory as-is to the output, and the WebView will load them lazily when needed.
Measuring the Frontend Bundle
After a production build, run npx vite-bundle-visualizer or use rollup-plugin-visualizer directly to see an interactive treemap of every module in the bundle. Look for unexpectedly large dependencies—for instance, a utility library imported in its entirety when you only use a single function. Replace such imports with tree-shakeable alternatives or dynamic imports.
Managing Resources and Assets
Resources include images, icons, external binaries (sidecars), and any other files that ship alongside your app. Mismanaging them can bloat the installer size and slow down startup if the app tries to load everything at once.
Image Formats and Lazy Loading
Use modern image formats like WebP or AVIF that compress better than PNG or JPEG at comparable quality. The public/ directory is a good home for these assets. In the frontend, use the native loading="lazy" attribute on <img> tags so images below the fold don’t block the initial render.
function UserAvatar({ src, alt }: { src: string; alt: string }) {
return (
<img
src={src}
alt={alt}
loading="lazy"
className="rounded-full w-12 h-12 object-cover"
/>
);
}
This defers loading until the image is close to the viewport, reducing network requests (or in Tauri’s case, asset protocol requests) at startup.
External Binaries and Sidecars
If you include external binaries via the externalBin configuration, each binary is bundled into the platform-specific installer. Keep these binaries lean by stripping them and ensuring they are built in release mode. Tauri does not optimize your sidecar binaries for you—they are included as-is. Apply the same Rust release profile principles to any sidecar you write in Rust, or use the smallest possible binary for the job.
{
"bundle": {
"externalBin": [
"binaries/my-sidecar"
]
}
}
Sidecars and Startup Time:
If you launch a sidecar process at application startup, its initialization time adds to the perceived boot time. Start sidecars lazily when the user first needs their functionality, and consider keeping long-running sidecars alive as a pool to amortize the startup cost.
Build Pipeline Optimization
The beforeBuildCommand and beforeDevCommand fields in the Tauri build config let you hook into the build lifecycle. Use them to enforce production optimizations on every build—for example, ensuring the frontend is built with a production flag.
{
"build": {
"beforeBuildCommand": "npm run build -- --mode production",
"beforeDevCommand": "npm run dev"
}
}
By explicitly pinning the production mode, you prevent accidental debug-mode frontend bundles from sneaking into release builds. The --mode production flag ensures that Vite uses the .env.production file and applies all production optimizations, even if a developer’s local shell has a different NODE_ENV.
Double-Check BeforeBuildCommand Failures:
If beforeBuildCommand exits with a non-zero status, Tauri’s build will halt. This is intentional—a broken frontend build should never produce a shippable application. Test your build command separately before integrating it into the Tauri config, and ensure it works across all developer machines and CI runners.
Putting It All Together
Here is a summarized workflow that ties the configuration pieces into a repeatable optimization pass.
Set the Cargo Release Profile
Open src-tauri/Cargo.toml and add the [profile.release] block with opt-level = "z", lto = "fat", codegen-units = 1, panic = "abort", and strip = true. Save the file. This will take effect on the next tauri build.
Enable Unused Command Removal
In src-tauri/tauri.conf.json, set build.removeUnusedCommands to true. This ensures only frontend-invoked commands stay in the final binary.
Configure Vite for Production
Update vite.config.ts to target esnext, disable source maps, and split vendor chunks. Verify the beforeBuildCommand runs the production Vite build.
Audit Frontend Imports and Assets
Use a bundle visualizer to inspect the output in dist/. Remove or split large dependencies. Ensure images use modern formats and lazy loading. Avoid inlining large data blobs.
Build and Measure
Run tauri build and compare the installer size and cold-start time against a baseline build. Check that all features still work correctly. If a particular optimization broke functionality, roll it back individually and re-test.
After this pass, your application should start faster, occupy less disk space, and consume fewer resources at runtime. The configuration stays close to the defaults of each tool—no fragile overrides that break with updates.
Performance optimization in Tauri is a balancing act between the Rust side and the frontend side. A tiny Rust binary does not compensate for a massive JavaScript bundle, and a perfectly tree-shaken frontend cannot hide a bloated sidecar binary. Both halves respond to configuration changes, and the settings above give you a solid baseline without diving into runtime profiling or rewriting core logic.