Flamegraphs over Tauri IPC: from Rust event to React paint

Why cross-IPC flamegraphs matter
Tauri apps hop the boundary on every interaction: Rust fires an event, JavaScript handles it, React commits, the compositor paints. If your frame blows the budget, who takes the hit? A cross-language flamegraph makes the blame obvious instead of inferred.
This article walks through a repeatable setup to:
- Carry a single correlation ID through Rust and React for one user flow.
- Send Rust spans (with arguments) to Chrome Trace Events using
tracing-chrome. - Capture React commit and paint via the User Timing API.
- Align clocks and merge everything into one trace that opens in Perfetto or chrome://tracing.
What we’ll build
- Rust:
tracingspans on the emitter path, persisted viatracing-chrometo a Chrome trace JSON. - IPC: a correlation ID that travels Rust payload -> React state -> commit.
- JS:
performance.mark/measurefor receive -> commit -> paint, exported as Chrome Trace format. - Merge: a Node script that shifts timestamps by a measured offset and concatenates both event sets.
Step 1: Instrument Rust with tracing-chrome
Add dependencies:
# Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "registry"] }
tracing-chrome = "0.7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "1", features = ["api-all"] }
Initialize a chrome layer, expose a clock endpoint, and emit an event. Spans carry corr_id plus useful args. The clock endpoint returns a monotonic timestamp for sync.
// src/main.rs
use std::{sync::atomic::{AtomicU64, Ordering}, time::{Duration, Instant}};
use tauri::{AppHandle, Manager};
use tracing::{info_span, instrument};
use tracing_subscriber::{layer::SubscriberExt, Registry};
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
static START: once_cell::sync::Lazy<Instant> = once_cell::sync::Lazy::new(Instant::now);
#[derive(serde::Serialize)]
struct Payload {
id: u64,
msg: String,
}
#[tauri::command]
fn rust_now_ns() -> u128 {
START.elapsed().as_nanos()
}
#[tauri::command]
#[instrument(skip(app), fields(corr_id))]
async fn do_work(app: AppHandle, msg: String) -> Result<(), String> {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
tracing::Span::current().record("corr_id", &id);
// Simulate some Rust-side work attributed to this frame.
let span = info_span!("prepare_payload", corr_id = id, len = msg.len());
let _e = span.enter();
tokio::time::sleep(Duration::from_millis(3)).await;
let payload = Payload { id, msg };
// Span around the actual emit as well.
let emit_span = info_span!("emit_all", corr_id = id, event = "work_done");
let _e2 = emit_span.enter();
app.emit_all("work_done", &payload).map_err(|e| e.to_string())?;
Ok(())
}
fn main() {
// Chrome trace layer dumps to a file your CI can collect.
let (chrome_layer, _guard) = tracing_chrome::ChromeLayerBuilder::new()
.include_args(true)
.build();
let subscriber = Registry::default().with(chrome_layer);
tracing::subscriber::set_global_default(subscriber).expect("set global subscriber");
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![do_work, rust_now_ns])
.run(tauri::generate_context!())
.expect("error while running tauri app");
}
Notes:
tracing-chromewrites a Chrome Trace Events file (defaults totrace-*.json) with microsecond timestamps. Keep the_guarduntil shutdown so it flushes.- Adding
corr_idto span fields makes the merged view show exactly which Rust spans belong to each frame.
Step 2: Calibrate clocks and propagate the ID
Rust’s Instant and the webview’s performance.now() are different clocks. Use a ping-pong to estimate an offset:
// src/ui/clock.ts
import { invoke } from "@tauri-apps/api";
export async function calibrateClock(samples = 10) {
// Returns {offsetUs, stddevUs} where
// ts_ui_ms ~= perf.now(); ts_rust_us = rust_now_ns / 1000; ts_ui_us = ts_ui_ms * 1000;
let best: {offsetUs: number; rttMs: number} | null = null;
for (let i = 0; i < samples; i++) {
const t0 = performance.now();
const rustNs = await invoke<number>("rust_now_ns");
const t1 = performance.now();
const midMs = (t0 + t1) / 2; // reduce IPC jitter bias
const uiUs = midMs * 1000;
const rustUs = Number(rustNs) / 1000;
const offsetUs = uiUs - rustUs; // add this to Rust-us to get UI-us
const rttMs = t1 - t0;
if (!best || rttMs < best.rttMs) best = { offsetUs, rttMs };
}
if (!best) throw new Error("clock calibration failed");
return best;
}
Pick the minimum round-trip sample to reduce jitter. Store offsetUs and apply it when merging.
Step 3: Mark React commit and paint
Listen for the Rust event, thread the id, and measure:
- receive -> setState
- commit via React Profiler
- next paint with
requestAnimationFrame
// src/ui/App.tsx
import React, { Profiler, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import { calibrateClock } from "./clock";
type Payload = { id: number; msg: string };
let OFFSET_US = 0; // UI_us - Rust_us
function mark(name: string, detail?: any) {
performance.mark(name);
if (detail) (performance as any).mark(name, { detail });
}
function AppInner() {
const [msgs, setMsgs] = useState<string[]>([]);
const lastCorr = useRef<number | null>(null);
useEffect(() => {
calibrateClock().then(({ offsetUs }) => (OFFSET_US = offsetUs));
const unlisten = listen<Payload>("work_done", (e) => {
const { id, msg } = e.payload;
lastCorr.current = id;
mark(`ipc:${id}:recv`);
setMsgs((xs) => [...xs, msg]);
});
return () => { unlisten.then((f) => f()); };
}, []);
// React Profiler gets commitTime in ms (UI clock)
const onRender: React.ProfilerOnRenderCallback = (
id, phase, actualDuration, baseDuration, startTime, commitTime
) => {
const corr = lastCorr.current;
if (corr == null) return;
performance.measure(`corr:${corr}:commit`, {
start: `ipc:${corr}:recv`,
end: performance.now(),
});
// Next paint boundary
requestAnimationFrame(() => {
mark(`corr:${corr}:paint`);
performance.measure(`corr:${corr}:recv->paint`, {
start: `ipc:${corr}:recv`,
end: `corr:${corr}:paint`,
});
});
};
return (
<Profiler id="App" onRender={onRender}>
<ul>
{msgs.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</Profiler>
);
}
export default AppInner;
DevTools’ Performance panel will now show your mark/measure ranges on the User Timing track, alongside React on the main thread. We still need Rust spans on the same axis.
Step 4: Export and merge traces
Export UI marks to Chrome Trace Events, then merge with the Rust file.
// src/ui/export.ts
// Build Chrome Trace Events from Performance entries in UI timebase (us)
export function buildUiTrace(cat = "ui", pid = 1, tid = 1) {
const events: any[] = [];
const marks = performance.getEntriesByType("mark");
const measures = performance.getEntriesByType("measure");
for (const m of measures as PerformanceMeasure[]) {
const name = m.name; // e.g., corr:123:recv->paint
const ts_us = m.startTime * 1000; // ms -> us
const dur_us = m.duration * 1000;
events.push({ name, cat, ph: "X", ts: ts_us, dur: dur_us, pid, tid, args: {} });
}
for (const mk of marks as PerformanceMark[]) {
const name = mk.name; // e.g., ipc:123:recv
const ts_us = mk.startTime * 1000;
events.push({ name, cat, ph: "i", s: "t", ts: ts_us, pid, tid, args: {} });
}
return { traceEvents: events };
}
Write a small Node script to combine the JSON files. tracing-chrome already uses microseconds; shift the Rust timestamps by OFFSET_US to land in UI time.
// scripts/merge-traces.js
// Usage: node merge-traces.js rust.json ui.json offset_us > merged.json
const fs = require('fs');
function load(p) { return JSON.parse(fs.readFileSync(p, 'utf8')); }
const [,, rustPath, uiPath, offsetArg] = process.argv;
const rust = load(rustPath); // { traceEvents: [...] }
const ui = load(uiPath);
const offsetUs = Number(offsetArg || 0);
const rustShifted = rust.traceEvents.map(e => ({ ...e, ts: e.ts + offsetUs }));
const all = rustShifted.concat(ui.traceEvents).sort((a,b) => a.ts - b.ts);
const merged = { traceEvents: all };
process.stdout.write(JSON.stringify(merged));
Workflow:
- Run the app and reproduce the path you care about.
- Rust writes a trace file (e.g.,
trace-<pid>.json). - In DevTools console, build the UI trace with
const ui = buildUiTrace();and save it viaJSON.stringify(ui)(or wire up a download). - Get
offsetUsfromcalibrateClock()(print once at startup and reuse). node merge-traces.js trace-rust.json ui.json <offsetUs> > merged.json.- Open
merged.jsonin chrome://tracing or https://ui.perfetto.dev/.
You’ll see a single timeline: Rust spans with corr_id next to your User Timing slices and paints.
Reading the cross-IPC flamechart
- Find
prepare_payloadandemit_allspans with the samecorr_id = N. - On the UI side, look for
ipc:N:recv, thencorr:N:commit, thencorr:N:recv->paint. - The gap from the end of Rust
emit_alltoipc:N:recvis bridge latency (IPC plus webview scheduling). - The window from
ipc:N:recvto React commit is reconciliation and render work. The rAF-to-paint step shows the compositor side.
With that, you can point to the side that missed the frame budget.
Production hardening and deeper stacks
- Rust CPU flamegraphs: combine
tracingwithpprofsampling under thecorr_idspan. Name the profile withcorr_idto align stacks to a frame. - JS CPU profile: capture a DevTools profile and filter to the
ipc:N:recv→ paint interval to isolate JS time. - Sampling overhead: keep tracing light in hot paths for release; gate it behind env flags and target only the areas you suspect.
- Drift: for long sessions, re-run
calibrateClock()and version the offset alongside trace chunks. - Multiple windows/threads: assign separate
pid/tidfor UI events to split tracks (pid=1 main, pid=2 worker), and propagatecorr_idend to end.
Platform notes
- Tauri WebView clocks: Windows (WebView2/Chromium) and macOS/Linux (WebKit) expose high-res
performance.now(). The ping-pong approach holds; use the minimum RTT. tracing-chromeuses microseconds. The UI exporter convertsperformance.now()from ms to µs to match.emit_allis synchronous in Rust, while delivery into the webview is asynchronous; measure that gap explicitly.
Minimal checklist
- Add
tracing-chrome; tag spans withcorr_id. - Emit Tauri events with
{ id }and keep key Rust work inside spans. - On the UI, measure
recv -> commit -> paintwith Performance marks and the React Profiler. - Calibrate clocks; shift Rust by
offsetUs. - Merge, open in Perfetto, and read one flamechart across IPC.
Do this once and it becomes routine. Every frame has a lineage, and you can trim the real bottlenecks instead of guessing.
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.