Halving Tauri v2 IPC: Batch Events + rmp-serde MessagePack

8 min readYaseen Khatib · MERN + AI Architect

Why your Tauri v2 IPC is slower than it should be

Round-tripping tiny JSON blobs across the Rust↔WebView bridge is deceptively expensive. The fixed overhead of each IPC hop (dispatch, serialization, V8 parse, JS callback) dominates, especially under chatty telemetry, logs, and UI metrics.

Two levers consistently move the needle:

  • Batch multiple logical events into one IPC envelope.
  • Encode with MessagePack (rmp-serde in Rust, @msgpack/msgpack in JS) to reduce CPU and payload overhead versus JSON.

This post shows a concrete, production-friendly pattern for both. It focuses on Tauri v2 APIs and works without custom native plugins. If you need raw bytes, see the advanced note at the end.

Strategy

  • Push or pull batches across the bridge at a fixed cadence (e.g., every 8–16 ms) and/or when a max size is reached.
  • Encode the batch with rmp-serde on Rust side into a Vec.
  • Transport over Tauri events or command return as base64 (safe across the JSON-only bridge). Yes, base64 adds ~33% size, but you still get fewer round-trips and faster (de)serialization than JSON.
  • Decode once per batch in the WebView; fan out to your app state.

Rust: rmp-serde batcher and Tauri v2 wiring

Cargo.toml (relevant parts):

[dependencies]
tauri = { version = "2", features = ["macros"] }
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
base64 = "0.22"
anyhow = "1"
# Use Tauri's async runtime re-export to avoid direct tokio coupling

A minimal event type. Use an enum with a compact tagged representation for small payloads:

// src/models.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "t", content = "p")] // compact tag + content layout
pub enum Evt {
  Log { level: u8, msg: String },
  Telemetry { ts: u64, x: f32, y: f32 },
}

The batcher accumulates events and encodes when time/size thresholds are met:

// src/batcher.rs
use crate::models::Evt;
use rmp_serde::to_vec_named as to_msgpack;
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

#[derive(Clone)]
pub struct Batcher {
  inner: Arc<Mutex<Inner>>,
}

struct Inner {
  buf: Vec<Evt>,
  bytes_estimate: usize,
  max_events: usize,
  max_bytes: usize,
}

impl Batcher {
  pub fn new(max_events: usize, max_bytes: usize) -> Self {
    Self {
      inner: Arc::new(Mutex::new(Inner {
        buf: Vec::with_capacity(max_events * 2),
        bytes_estimate: 0,
        max_events,
        max_bytes,
      })),
    }
  }

  pub fn push(&self, evt: Evt) -> bool {
    let mut g = self.inner.lock().unwrap();
    g.bytes_estimate += Self::estimate(&evt);
    g.buf.push(evt);
    g.buf.len() >= g.max_events || g.bytes_estimate >= g.max_bytes
  }

  pub fn take_and_encode(&self) -> Option<Vec<u8>> {
    let mut g = self.inner.lock().unwrap();
    if g.buf.is_empty() { return None; }
    let mut tmp = Vec::new();
    std::mem::swap(&mut tmp, &mut g.buf);
    g.bytes_estimate = 0;
    Some(to_msgpack(&tmp).expect("msgpack encode"))
  }

  fn estimate(evt: &Evt) -> usize {
    match evt {
      Evt::Log { msg, .. } => 4 + msg.len(),
      Evt::Telemetry { .. } => 16,
    }
  }
}

Wire it into Tauri v2. We expose two commands: push_event and drain_batch. We also run a periodic flusher that emits to all windows.

// src/main.rs
mod models; mod batcher;

use crate::batcher::Batcher;
use crate::models::Evt;
use anyhow::Result;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use std::sync::Arc;
use tauri::{async_runtime, Emitter, Manager, State};

struct Shared(Batcher);

#[tauri::command]
fn push_event(batch: State<Shared>, evt: Evt) {
  // If thresholds hit, we let the background task flush on the next tick.
  let _should_flush = batch.0.push(evt);
}

#[tauri::command]
fn drain_batch(batch: State<Shared>) -> Option<String> {
  batch.0.take_and_encode().map(|bytes| B64.encode(bytes))
}

fn main() {
  tauri::Builder::default()
    .setup(|app| {
      let batcher = Batcher::new(/*max_events*/ 256, /*max_bytes*/ 64 * 1024);
      app.manage(Shared(batcher.clone()));

      // Periodic push-mode emitter to all windows
      let app_handle = app.handle().clone();
      async_runtime::spawn(async move {
        use std::time::Duration;
        let mut interval = async_runtime::time::interval(Duration::from_millis(8));
        loop {
          interval.tick().await;
          if let Some(bytes) = batcher.take_and_encode() {
            let b64 = B64.encode(bytes);
            let _ = app_handle.emit("rmp_batch", &b64);
          }
        }
      });
      Ok(())
    })
    .invoke_handler(tauri::generate_handler![push_event, drain_batch])
    .run(|_app, _event| {})
    .expect("error running tauri app");
}

Notes:

  • We use emit("rmp_batch", &b64) to broadcast base64-encoded MessagePack to all WebViews.
  • Pull mode is available via invoke('drain_batch') from JS to reduce push pressure or to control backpressure explicitly.
  • The estimate function is a heuristic to avoid frequent encoding; the hard limit is enforced by max_bytes.

WebView: decoding MessagePack batches efficiently

Install a small, fast decoder:

npm i @msgpack/msgpack

Hook both push and pull flows. Decode once per batch and fan-out to your reducers.

// src/ipc.ts
import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { decode } from '@msgpack/msgpack'

function b64ToU8(b64: string): Uint8Array {
  const bin = atob(b64)
  const buf = new Uint8Array(bin.length)
  for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i)
  return buf
}

type LogEvt = { t: 'Log', p: { level: number; msg: string } }
type TelemetryEvt = { t: 'Telemetry', p: { ts: number; x: number; y: number } }

type Evt = LogEvt | TelemetryEvt

function handleBatch(events: Evt[]) {
  // single render transaction / reducer batch
  for (const e of events) {
    switch (e.t) {
      case 'Log':
        console.debug('[native]', e.p.level, e.p.msg)
        break
      case 'Telemetry':
        // enqueue into your ring buffer, draw, etc.
        break
    }
  }
}

// Push mode: native emits `rmp_batch` periodically
export async function initIpcPushMode() {
  await listen<string>('rmp_batch', (ev) => {
    const bytes = b64ToU8(ev.payload)
    const arr = decode(bytes) as Evt[]
    handleBatch(arr)
  })
}

// Pull mode: call when idle or on a timer to drain
export async function drainOnce() {
  const b64 = await invoke<string | null>('drain_batch')
  if (!b64) return
  handleBatch(decode(b64ToU8(b64)) as Evt[])
}

// Example: pull at 60Hz if nothing else drives it
export function startPullLoop() {
  let running = true
  const tick = async () => {
    if (!running) return
    await drainOnce()
    requestAnimationFrame(tick)
  }
  requestAnimationFrame(tick)
  return () => { running = false }
}

Guidelines:

  • Prefer push for low latency streams; prefer pull to align with your render loop and enforce backpressure.
  • Always decode once per batch and do a single state update per frame to avoid layout thrashing.

Does MessagePack help if we still base64?

Yes, because:

  • CPU: rmp-serde and @msgpack/msgpack avoid deep JSON stringify/parse work for many nested objects.
  • Payload: MessagePack is ~20–50% smaller than JSON for typical telemetry. Base64 adds ~33%, so size savings vary; however, the dominant win is fewer IPC crossings via batching.

Back-of-the-envelope comparison for 10k events/sec, each ~48B logical content:

  • JSON, unbatched: ~10k invokes + JSON parse/stringify; payload ~1.6–2.2× expansion → 0.8–1.1 MB/s.
  • MsgPack + base64, batched at 250 events: ~40 emits/sec; payload roughly similar or slightly smaller than JSON, but with 250× fewer crossings and cheaper (de)serialization per object.

In practice, I see 2–4× less CPU in both Rust and JS for the same stream, and much lower jank from reduced GC pressure and callback churn.

Tuning knobs that matter

  • Flush cadence: 8–16 ms aligns with 60–120 Hz render budgets. Lower if you need latency; higher if you need throughput.
  • Max events and max bytes: guardrails to avoid oversized payloads. I use 256 events and 64–128 KB.
  • Event schema: Compact enums with #[serde(tag = "t", content = "p")] cut map overhead and JS property churn.
  • Threading: Push into the batcher from any thread; the periodic flush runs on Tauri’s async runtime.

Pitfalls and gotchas

  • Strings vs bytes: Tauri’s default event/command pipe is JSON; hence the base64 hop. If you must avoid base64, use a custom protocol (see below).
  • Dates, bigints, and floats: MessagePack supports them as numbers; JS decoders map 64-bit ints to JS numbers (53-bit safe). Send timestamps as u64 but validate on JS if ranges exceed 2^53-1; or encode as strings.
  • Backpressure: If the JS side ignores batches, buffers grow. Pull mode helps. In push mode, drop batches if the UI is busy.
  • Schemas: Validate once in Rust, not in JS, to remove expensive runtime checks. If you must in JS, precompile zod/superstruct schemas and run on batch boundaries.

Advanced: true binary over the bridge

If you control both ends and want zero base64 tax:

  • Register a custom protocol endpoint (e.g., tauri://ipc/batch) and serve application/msgpack from Rust. Then fetch('tauri://ipc/batch') in JS and await resp.arrayBuffer() to get raw bytes. This keeps MsgPack binary end-to-end.
  • Or implement a small plugin command that returns Vec<u8> via a binary-safe channel. As of v2, the stock invoke/event path is JSON; plugins or custom protocol are the escape hatch.

Both approaches preserve the batching logic shown here.

Quick checklist

  • Batch all high-frequency streams (logs, telemetry, UI perf marks).
  • Encode with rmp-serde on Rust and decode with @msgpack/msgpack on JS.
  • Flush on time and size thresholds.
  • Prefer a single state transition per batch in the WebView.
  • Move to a custom protocol if you need raw bytes.

Where to go next

  • Add sequence numbers to batches; detect drops and request replay.
  • Split channels by topic and priority to avoid head-of-line blocking.
  • Pre-size vectors and reuse buffers for zero-alloc hot paths.
  • Profile with flamegraphs on both ends; IPC wins shouldn’t regress your main thread.

Halve the IPC cost first with batching; MessagePack compounds the win. After that, only move to binary transport if your payload volume justifies the plumbing.

Looking to architect a similar system?

Let's ship it at AI-speed.

Start a conversation →