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

Shuttling small JSON payloads over the Rust↔WebView bridge costs more than it looks. The constant work per hop—dispatch, serialization, V8 parse, JS callback—dominates, especially with noisy telemetry, logs, and UI metrics.

Two changes move real numbers:

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

Below is a concrete, production-ready pattern that does both. It targets Tauri v2 and needs no native plugins. If you want raw bytes, see the advanced note at the end.

Strategy

  • Send batches on a fixed cadence (about 8–16 ms) and/or when a size cap trips.
  • Encode the batch on the Rust side with rmp-serde into a Vec.
  • Carry it over Tauri events or command returns as base64, which is safe across the JSON-only bridge. Base64 adds ~33% size, but you still win on round-trips and (de)serialization cost compared to JSON.
  • Decode once per batch in the WebView and then 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

Define a small event type. An enum with a compact tagged layout keeps payloads lean:

// 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 collects events and encodes when time/size thresholds trip:

// 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. Expose two commands, push_event and drain_batch, and 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:

  • emit("rmp_batch", &b64) broadcasts base64-encoded MessagePack to all WebViews.
  • Pull mode is available with invoke('drain_batch') from JS if you want to dial back push pressure or manage backpressure directly.
  • The estimate function is a heuristic to reduce eager encoding; max_bytes remains the hard stop.

WebView: decoding MessagePack batches efficiently

Install a small, fast decoder:

npm i @msgpack/msgpack

Wire up push and pull. Decode once per batch, then fan out to 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:

  • Use push for low-latency streams; use pull to align with the render loop and enforce backpressure.
  • Decode once per batch and do a single state update per frame to avoid layout thrash.

Does MessagePack help if we still base64?

Yes, because:

  • CPU: rmp-serde and @msgpack/msgpack avoid deep JSON stringify/parse for lots of nested objects.
  • Payload: MessagePack is ~20–50% smaller than JSON for typical telemetry. Base64 adds ~33%, so size gains vary; the larger win comes from 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, with 250× fewer crossings and cheaper (de)serialization per object.

In practice, expect roughly 2–4× less CPU on both Rust and JS for the same stream, plus less jank from reduced GC and callback churn.

Tuning knobs that matter

  • Flush cadence: 8–16 ms lines up with 60–120 Hz budgets. Go lower for latency, higher for throughput.
  • Max events and max bytes: keep payloads bounded. 256 events and 64–128 KB work well.
  • Event schema: Compact enums with #[serde(tag = "t", content = "p")] trim 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: the default event/command path is JSON, so base64 is required. If you must avoid base64, use a custom protocol (see below).
  • Dates, bigints, and floats: MessagePack carries them as numbers; JS decoders map 64-bit ints to JS numbers (53-bit safe). Send timestamps as u64 but validate in JS if they can exceed 2^53-1, or encode as strings.
  • Backpressure: If JS doesn’t handle batches, buffers grow. Pull mode helps. In push mode, drop batches if the UI is busy.
  • Schemas: Validate once in Rust to avoid expensive JS checks. If you must validate in JS, precompile zod/superstruct and run at batch boundaries.

Advanced: true binary over the bridge

If you control both ends and want to skip base64:

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

Both approaches keep the batching logic intact.

Quick checklist

  • Batch high-frequency streams (logs, telemetry, UI perf marks).
  • Encode with rmp-serde in Rust and decode with @msgpack/msgpack in JS.
  • Flush on time and size thresholds.
  • Prefer one 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 prevent head-of-line blocking.
  • Pre-size vectors and reuse buffers on hot paths to avoid allocations.
  • Profile with flamegraphs on both ends; don’t win on IPC only to regress the main thread.

Start by cutting IPC cost with batching; MessagePack amplifies the gain. Only switch to binary transport when the payload volume makes the extra plumbing worthwhile.

Need an engineer who can build this?

I'm Yaseen Khatib — a Senior Full-Stack AI Engineer (MERN + TypeScript) who ships production AI systems solo. Open to senior and lead roles, remote or on-site.