Flamegraphs over Tauri IPC: from Rust event to React paint

8 min readYaseen Khatib · MERN + AI Architect
Cover illustration: 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: tracing spans on the emitter path, persisted via tracing-chrome to a Chrome trace JSON.
  • IPC: a correlation ID that travels Rust payload -> React state -> commit.
  • JS: performance.mark/measure for 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-chrome writes a Chrome Trace Events file (defaults to trace-*.json) with microsecond timestamps. Keep the _guard until shutdown so it flushes.
  • Adding corr_id to 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 via JSON.stringify(ui) (or wire up a download).
  • Get offsetUs from calibrateClock() (print once at startup and reuse).
  • node merge-traces.js trace-rust.json ui.json <offsetUs> > merged.json.
  • Open merged.json in 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_payload and emit_all spans with the same corr_id = N.
  • On the UI side, look for ipc:N:recv, then corr:N:commit, then corr:N:recv->paint.
  • The gap from the end of Rust emit_all to ipc:N:recv is bridge latency (IPC plus webview scheduling).
  • The window from ipc:N:recv to 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 tracing with pprof sampling under the corr_id span. Name the profile with corr_id to 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/tid for UI events to split tracks (pid=1 main, pid=2 worker), and propagate corr_id end 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-chrome uses microseconds. The UI exporter converts performance.now() from ms to µs to match.
  • emit_all is synchronous in Rust, while delivery into the webview is asynchronous; measure that gap explicitly.

Minimal checklist

  • Add tracing-chrome; tag spans with corr_id.
  • Emit Tauri events with { id } and keep key Rust work inside spans.
  • On the UI, measure recv -> commit -> paint with 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.