All Blogs
Using Bun or Deno as a web server in Tauri

Using Bun or Deno as a web server in Tauri


IMPORTANT

I’ve found a better way to communicate between Bun server and Tauri client using bi-directional RPC system that requires zero extra Rust code and also completely eliminates the requirement for authorization for security. It is available on branch v2 in GitHub Repo. Please skip to this section to read the newer implementation detail.

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 in extremely lightweight app sizes. A simple “Hello World!” app on Tauri just weighs ~2.5MB, whereas the same version using Electron weighs ~90MB. 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 the event loop and async I/O in the program. But JavaScript is an interpreted language, and it manages memory via a GC heap. Rust uses an 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, besides the exorbitant app size.

Electron meme

But, there’s one thing that I think is deterring developers from 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 has a steep learning curve, and Rust’s borrow-checker trips even 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 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, Python, or any other language as a backend. But, since Bun and Deno have emerged as performant Node.js replacements focusing on consuming less memory and improving the Node.js performance bottlenecks, this blog will focus on them only.

Before we start, here’s the complete code for Bun and Deno versions.

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 its own HTTP client library to get full potential (this is very 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.

Tauri Bun

Sweet! But, right now, the Bun server is running separately in 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 on any platform without having to install any dependencies. They’re great for CLI applications and, in fact, Claude Code recently used Bun’s single-file executable to ship their agentic CLI app. I used the 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 needs.

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, 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 on which you’re running the application. Meaning, you need to create an executable binary for each platform - MacOS, Windows, and Linux. After creating the binary, we need to place the sidecar binary inside the tauri/bin folder with the 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 on 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🪄.

Compile

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:

Sidecar Spawn

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.

ProcessFlow

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 Tauri, 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 to you with a 200 status. We need to allow the incoming requests only if it was triggered via our Tauri webview (or Rust backend), otherwise we need to reject them. 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 problems.

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 the Rust process, the 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 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. The Bun server now needs to verify and confirm that these tokens are valid and requested via the Tauri webview, and it does the verification with the Rust backend. If the token is valid, it’ll resolve the request, otherwise it rejects it.

HTTPAuth

The secret key lives only in the Rust process, and it is a per-session key that will be destroyed when the Tauri process ends. You can also create a central secret key and store it using full encryption with Stronghold if you want to make sure only one instance of the server runs and multiple instances 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 the 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 the single-file executable 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 ~60MB of our JS runtime bundled web server code, the final app builds were just under 30MB. Compared to Electron, this is a substantially smaller app build size.

Final Thoughts

As I said in the beginning, most of the desktop apps do not require a custom web server. In the case of Tauri, the Rust-Webview IPC is pretty solid, and you should aim for that most of the time. But sometimes a custom web server becomes a necessity, regardless of whether 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 with a similar concept using Node.js as a runtime web server. Today, Bun and Deno offer better single-file executable ability and efficient memory footprint, and I think they’re a better fit to use as a sidecar.

Update: Version 2 with Bi-Directional RPC

Recently, I discovered a brand new RPC library called kkrpc that handles RPC connection across various environments like http, websocket, stdio, web worker, iframe, etc. It also has a first-class support for Tauri. With this new setup, we don’t even need any extra Rust code at all and also eliminates the need for authorization, compared to what we did in earlier setup. Furthermore, it is bi-directional so it just acts like a WebSocket server. It is very easy to setup and results in a fully typed bi-directional APIs between server and client.

ProcessFlowV2

Server setup:

We’ll first create some basic APIs and initialize our RPC channel in our server first.

import { BunIo, RPCChannel } from 'kkrpc'

import { api as serverApi } from './api'
import { type API as ServerApi } from './types/api'
import { type API as ClientApi } from '../../client/src/types/api'

const stdio = new BunIo(Bun.stdin.stream())
export const rpc = new RPCChannel<ServerApi, ClientApi>(stdio, {
  expose: serverApi,
})

// biome-ignore lint/suspicious/noConsole: <>
console.log(`Bun server is running.`)

Here, we’ve initialized our bi-directional Bun RPC server via Bun stdin/stdout stream as a channel. The ServerApi and ClientApi are just server and client API interfaces for type hints. I’ve created some simple server APIs for this demo.

import type { API as ServerApi } from '../types/api'

export const api: ServerApi = {
  add: async (a: number, b: number) => a + b,
}

Client Setup:

Now, let’s setup the RPC connection in our Tauri webview client. We’ll initiate it in our root router.tsx to ensure that the RPC connection is live before our frontend mounts.

// router.tsx
import { Command } from '@tauri-apps/plugin-shell'
import { RPCChannel, TauriShellStdio } from 'kkrpc/browser'
import ReactDOM from 'react-dom/client'

import { api as clientApi } from './api'
import { type API as ClientApi } from './types/api'
import { type API as ServerApi } from '../../server/src/types/api'

export type RouterContext = {
  serverApi: ServerApi
}

//  Start the server
const cmd = Command.sidecar('bin/tauri-bun-sidecar')
const process = await cmd.spawn()
const stdio = new TauriShellStdio(cmd.stdout, process)
const channel = new RPCChannel<ClientApi, ServerApi>(stdio, {
  expose: clientApi,
})
const serverApi = channel.getAPI()

const router = createRouter({
  routeTree,
  defaultPreload: 'intent',
  context: { serverApi } as RouterContext,
})

const rootElement = document.getElementById('app')!
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(<RouterProvider router={router} />)
}

Here’re we’ve initialized our RPC connection with our Bun RPC channel via Tauri Sidecar. After the connection is established over the bi-directional channel, we’re passing the api instance serverApi as a global router context. Now, we can use this RPC connection to call our server API anywhere in our React app.

const result = await serverApi.add(1,2)
// 3

Communication between Rust and Bun Server

Right now, Tauri webview can communicate with Rust backend and with Bun server as well. But, there’s no direct way for Bun server to communicate with Rust backend. But, there’s a way to communicate with Rust at any point in Bun server via Tauri commands, thanks to bi-directional RPC channel. Let’s add a simple Tauri command.

#[tauri::command]
pub fn my_custom_command() -> String {
    "Hello Bun!".to_string()
}

Now, let’s add an API to call this command from Tauri webview client.

import { core } from '@tauri-apps/api'

import { API as ClientApi } from '~/types/api'

export const api: ClientApi = {
  async askRust() {
    return await core.invoke<Promise<string>>('my_custom_command')
  },
}

Finally, we can now call this client API easily at any point in our Bun server.

import { rpc as serverRpc } from '..'
import type { API as ServerApi } from '../types/api'

export const api: ServerApi = {
  add: async (a: number, b: number) => a + b,W
  tasks: {
    async task1() {
      const clientApi = serverRpc.getAPI() // Access the client API
      const result = await clientApi.askRust() // This will call Rust backend and get the result.
      return `Bun got this message from Rust: "${result}"`
    },
  },
}
Tauri Bun RPC

Code: Please switch to branch v2 for RPC based setup. The old setup is still available on main branch.


Tauri + Bun and Tauri + Deno