Halving Tauri v2 IPC: Batch Events + rmp-serde MessagePack
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_bytesremains 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 serveapplication/msgpackfrom Rust. In JS,fetch('tauri://ipc/batch')andawait 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.