Using Bun or Deno as a web server in Tauri
Tauri is a Rust🦀 framework to build a cross-platform desktop app using Rust as a backend and webview as a frontend layer. Unlike Electron, Tauri does not bundle the whole Chromium browser into the final app, it uses the same native webview that the platform ships with. On MacOS, that is Safari WebKit, on Windows that is Edge WebView2 (Chromium-based) and WebKitGTK on Linux. This results extremely lightweight app sizes. A simple “Hello World!” app on Tauri just weighs ~2.5 MB whereas the same version on using Electron weighs ~90 MB. That is about 30x smaller app bundle compared to Electron.
Tauri uses Rust as a backend layer whereas Electron uses Node.js. Rust is a statically typed and compiled programming language that is known for its performance. Node.js is built on top of V8, a high performance JavaScript engine written in C++, and libuv, a C library that manages event loop and async I/O in the program. But, JavaScript is a interpreted language and it manages memory via a GC heap. Rust uses ownership model to manage memory manually and has no GC. Due to this reason, Rust is highly performant compared to Node. Electron apps are profusely known for consuming too much memory footprint beside the exorbitant app size.
But, there’s one thing that I think is deterring developers in fully adopting Tauri to develop their desktop applications. And that is Rust. Let’s admit it, Rust is a difficult language to learn, let alone master it. It has a steep learning curve and the Rust’s borrow-checker trips even the seasoned developers. Of course, with the advancements of modern LLMs, that might sound irrelevant but you still need to have a good understanding of Rust to use and understand Tauri. While I still recommend you to use the existing Rust backend, you can actually use Bun or Deno as a backend in your Tauri apps. In fact, you can even use Node or Python or any other language as a backend. But, since Bun and Deno have emerged as performant Node.js replacement focusing on consuming less memory and improve the Node.js performance bottlenecks, this blog will focus on them only.
Before we start, here’s the complete code for Bun and Deno version.
Initial Setup:
Let us first setup a simple HTTP web server in Bun:
function handleCors(_: Request, res: Response) {
res.headers.set('Access-Control-Allow-Origin', '*')
res.headers.set('Access-Control-Allow-Headers', '*')
res.headers.set('Access-Control-Allow-Credentials', 'true')
return res
}
const server = Bun.serve({
port: 3000,
hostname: '127.0.0.1',
async fetch(req: Request) {
if (req.method === 'OPTIONS') {
return handleCors(req, new Response(null, { status: 204 }))
}
const url = new URL(req.url)
if (url.pathname === '/') {
return handleCors(
req,
new Response(JSON.stringify({ data: 'Hello from Bun!' })),
)
}
if (url.pathname === '/ping') {
return handleCors(req, new Response(JSON.stringify({ data: 'pong' })))
}
return handleCors(req, new Response('Not Found', { status: 404 }))
},
})
Start the server using bun dev from the folder apps/server.
Now, let’s try to connect this server from our Tauri webview. We’ll be using a simple React SPA with TanStack Router as our frontend setup. You can browse the code within apps/client.
function App() {
const { data } = useQuery({
queryKey: ['/'],
queryFn: () => fetcher('/'),
})
return (
<>
<div className="h-screen w-screen flex items-center justify-center flex-col">
<img src={Logo} alt="logo" width={200} />
<h1 className="text-white text-3xl font-bold">Tauri + Bun</h1>
<div>
<Link to="/dashboard" className="text-blue-500 block my-2">
Go to Dashboard
</Link>
</div>
<p className="text-white">{JSON.stringify(data, null, 2)}</p>
</div>
</>
)
}
To run the frontend client, head over to apps/client and run bun dev.
Note: Tauri requires you to use it’s own HTTP client library get full potential (this is annoying actually), so the fetcher function you’re seeing above is a custom function that I made for convenience. You can find it in apps/client/utils/fetcher.ts.
Now, let’s finally Run our Tauri app using the command bun tauri:dev. You should see the following output.
Sweet! But, right now, the Bun server is running separately on its own folder. When we ship our desktop app to the users, we need to somehow embed that Bun server into the final app bundle. Bun supports something called standalone binary mode or single-file executable out of the box. With this, you can create a single executable file that can be run in any platform without having to install any dependencies. They’re great for CLI application and in fact, Claude Code recently used Bun single-file executable to ship their agentic CLI app. I used sidecar feature 2 years ago in my free video compression app CompressO where FFmpeg is embedded as a sidecar binary to act as a media conversion layer. They’re really powerful if you want to quickly ship your app without having to rely on any platform or external dependencies. This is perfect for our need.
Standalone Binary as a Tauri Sidecar
Tauri has a feature called Sidecar which allows you to embed the external binaries into the app. After you embed the binaries, the Rust can spawn that binary to perform the task it was bundled for. One requirement for embedding the binary as a sidecar in Tauri is that, the binary must match the target platform you’re running the application. Meaning, you need to create a executable binary for each platform - MacOS, Windows and Linux. After creating the binary, we need to place the sidecar binary inside tauri/bin folder with respective target name. For example, if you created a binary for MacOS, name the binary executable as <any_name>-aarch64-apple-darwin for Silicon(arm64) Macs and <any_name>-x86_64-apple-darwin for Intel(x86-64) Macs. This is very common if you want to distribute the binary in different platforms and you can read about these extensively on Tauri and Bun docs.
For our convenience, I’ve prepared a script that can detect your machine architecture and create a respective Bun binary automatically. You can browse the code for that in apps/server/scripts/compile.ts. Go ahead and run the command:
bun run --filter server compile
Your Bun server code will be compiled automatically and will be linked to the folder tauri/bin on the Rust side automatically🪄.
Now, on the Rust side, we need to inform Tauri that we want to use the binary as a sidecar in our app. You can do that in your tauri.conf.json
{
...,
"bundle": {
...
"externalBin": ["./bin/tauri-bun-sidecar"],
}
}
Here tauri-bun-sidecar is the name of our binary and it must match the name of the binary file that was created earlier using Bun compilation.
Last step is to spawn this binary from our Rust code.
pub struct AppState {
pub server: Arc<Mutex<Option<CommandChild>>>,
pub server_port: u16,
}
pub fn start_server(app_handle: &AppHandle, server_port: u16){
println!("[sidecar] Starting server...");
if let Some(app_state) = app_handle.try_state::<AppState>() {
if app_state.server.lock().is_some() {
println!("[sidecar] Server is already running.");
return Ok(());
}
}
let shell = app_handle.shell();
let mut sidecar = shell
.sidecar("tauri-bun-sidecar")
.map_err(|err| err.to_string())?;
sidecar = sidecar.env("PORT", server_port.to_string()); // pass a custom port to the server
let (mut rx, child) = sidecar.spawn().map_err(|err| err.to_string())?; // spawn the binary
if let Some(app_state) = app_handle.try_state::<AppState>() {
let mut server_lock = app_state.server.lock();
*server_lock = Some(child); // store the server instance in our Tauri app state
}
// listen for any output or errors from our sidecar
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(data) => {
if let Ok(text) = String::from_utf8(data) {
let line = text.trim();
println!("[sidecar] Server stdin {}", line);
}
}
CommandEvent::Stderr(data) =>
{
if let Ok(text) = String::from_utf8(data) {
eprintln!("[sidecar] Server stderr {}", text.trim());
}
}
CommandEvent::Terminated(code) => {
println!(
"[sidecar] Server terminated unexpectedly with code {:?}",
code
);
}
_ => {}
}
}
});
Ok(())
}
We need to now start the server whenever out Tauri app starts:
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.setup(|app| {
let listener = TcpListener::bind("127.0.0.1:0")?; // we'll choose a random available port every time we start the app
let server_port = listener.local_addr()?.port();
let app_state = AppState {
server: Arc::new(Mutex::new(None)),
server_port: server_port,
};
app.manage(app_state);
if let Err(err) = server::start_server(app.handle(), server_port) {
println!("[sidecar] Failed to start the server: {}", err);
}
Ok(())
})
.invoke_handler(tauri::generate_handler![get_server_config])
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run();
}
Let’s run the Tauri development server again:
bun tauri:dev
You should now the see the following output:
Our server is now running from Rust 🎉. This is not a development server running on your device. This is a complete release (production) server running within the Tauri app.
You can create a release build of your app to test the complete production application if you want using the command:
bun tauri:build
Security
But there’s one huge vulnerability with this setup. The server although being embedded into the Tauri, it can still be accessed by the outside world. If you try to visit the local address that the server is running from any other browser, it’ll respond you with 200 status. We need to only allow resolve the incoming requests only if it was triggered via our Tauri webview (or Rust backend) otherwise need to reject it. For that we need to come up with some kind of authentication between our Tauri webview and Bun server. Fortunately, there is a solution for such problem.
Solution
What we can do is, every time our Tauri app starts, we can generate a secret key in our Rust backend and this secret key only lives within Rust process, webview and Bun server cannot have access to this secret at all (you’ll later realize why). Our Tauri webview will now request a new token generated with the unique per session secret key when the frontend starts for the first time. Since Tauri webview uses secure IPC, no any other client can request this token. Now, every time we make a request to our Bun server, we send this token within the authorization headers. Bun server now needs to verify and confirm that these tokens are valid and requested via Tauri webview and it does the verification with the Rust backend. If the token is valid, it’ll resolve the request otherwise rejects it.
The secret key lives only in the Rust process and it is a per session key which will be destroyed when Tauri process ends. You can also create a central secret key and store it using a full encryption with Stronghold if you want to make sure only one instance of server runs and multiple instance of the Tauri client need to share the server. The same authentication process can be used with the Websocket connection as well.
We’ll go ahead and implement the authentication check in the Bun server first.
import { randomUUID } from 'node:crypto'
import readline from 'node:readline'
export const verificationStore = new Map<string, (v: boolean) => void>()
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
// Function to listen messages from Rust process
export function listenRustIPC() {
rl.on('line', async (line) => {
if (line.startsWith('[verify-token-response]')) {
try {
const responseStr = line.slice('[verify-token-response]'.length).trim()
const response: { id: string; valid: boolean } = JSON.parse(responseStr)
if (response.id) {
const resolver = verificationStore.get(response.id)
if (resolver) {
resolver(response.valid ?? false)
verificationStore.delete(response.id)
}
}
} catch {}
}
})
}
// Function to send request to Rust process
export function sendToRust(msg: string) {
process.stdout.write(`${msg}\n`)
}
export function verifyAuthToken(authToken: string) {
return new Promise<boolean>((resolve) => {
const id = randomUUID()
verificationStore.set(id, resolve)
sendToRust(`[verify-token] ${JSON.stringify({ id, token: authToken })}`)
})
}
export async function authMiddleware(req: Request): Promise<Response | null> {
const authHeader = req.headers.get('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 })
}
const token = authHeader.slice('Bearer '.length)
const valid = await verifyAuthToken(token)
if (!valid) {
return new Response('Unauthorized', { status: 401 })
}
return null // proceed
}
Now in our Bun server instantiation, we’ll apply this auth middleware to only accept authorized requests.
const server = Bun.serve({
...
async fetch(req: Request) {
...
const authResp = await authMiddleware(req)
if (authResp) return handleCors(req, authResp)
...
},
})
Every time a new request is received in the Bun server, we send the auth token to Rust process to verify it. Now, let’s move the Rust implementation.
pub fn start_server(app_handle: &AppHandle, server_port: u16){
...
CommandEvent::Stdout(data) => {
if let Ok(text) = String::from_utf8(data) {
let line = text.trim();
// We receive the token verification here
if line.starts_with("[verify-token]") {
let json_str = line
.strip_prefix("[verify-token]")
.expect("[sidecar] Invalid prefix")
.trim();
if let Ok(payload) = serde_json::from_str::<Value>(json_str) {
let id = payload.get("id").and_then(|v| v.as_str());
let token = payload.get("token").and_then(|v| v.as_str());
match (id, token) {
(Some(id), Some(token)) => {
// We grab the secret key stored in app state
if let Some(app_state) =
app_handle_clone2.try_state::<AppState>()
{
// We verify the token and respond if it is valid or not.
let claims = crypto::verify_token(
&app_state.app_secret_key,
token,
);
let response_json = serde_json::json!({
"id": id,
"valid": &claims.is_ok()
});
let response_str = format!(
"[verify-token-response] {}",
serde_json::to_string(&response_json).unwrap()
);
// We send the response back to the web server
send_to_server(&app_handle_clone2, &response_str).ok();
}
}
_ => eprintln!(
"[sidecar] Token verification is missing id or token field."
),
}
}
}
}
}
}
The fetcher function in the frontend that I mentioned earlier automatically injects the auth token that the Tauri webview generates on the first app run so we don’t need to worry about handling that.
That’s it! Every request now made to the web server must have a valid auth token that only Rust backend can generate and verify. If you now try to access the server address from your local browser, it’ll get rejected.
App Size
In terms of app size, for the same codebase of the web server, the single-file executable binary of Bun weighed ~60MB and the final Tauri app size was ~29MB (for .dmg in MacOS) whereas single-file executable of binary weighed ~73MB and the app size was ~36MB for the Deno version. The same version of the app using only Rust weighed just around ~5MB. Tauri does impressive compression while building the final release build so it’s not a surprise, with even massive around 60MB of our JS runtime bundled web server code, the final app builds were just under 30MB. Compared to Electron, this is a substantially small app build sizes.
Final Thoughts
As I said in the beginning, most of the desktop apps do not require a custom web server. In case of Tauri, the Rust-Webview IPC is pretty solid and you should aim for that most of the time. But some time a custom web server becomes a necessity regardless if you use a native Rust web server or a custom JS runtime based web server powered by Bun or Deno. A year ago I experimented on similar concept using Node.js as a runtime web server. Today, Bun and Deno offers better single-file executable ability and efficient memory footprint and I think they’re a better fit to use as a sidecar.
Code: Tauri + Bun and Tauri + Deno