Pinning a 1.8% Idle CPU: Evented vs Polling in Tauri Tray Apps

9 min readYaseen Khatib · MERN + AI Architect

The constraint: 1.8% idle CPU in a forever-on tray app

Long-running tray apps are power apps. They sit in the background, wake the CPU, trigger fans, and murder battery if you’re sloppy. A realistic budget on modern laptops (M1/M2, Intel Evo, Ryzen mobile) is ~1–2% CPU at idle with near-zero wakeups per second. The biggest lever is architectural: event-driven over polling.

This post shows a concrete, production-grade way to wire a Tauri tray app so it idles around 1.8% CPU by leaning on OS events, async I/O, and strict back-pressure.

Why polling fails (math, not vibes)

Every timer-based poll is a periodic wakeup. Wakeups prevent deep C-states, and short intervals eviscerate your idle target.

  • Wakeups per second = 1 / interval_seconds
  • CPU overhead grows with wakeup rate and handler cost; often the fixed scheduler and context-switch cost dominates.

Example (measured on M2 Pro, Release build, empty handler):

Interval Wakeups/s Observed CPU Notes
1s 1.0 2.6–3.2% Too high even with no work
5s 0.2 1.2–1.8% Barely acceptable if handler is trivial
60s 0.016 ~0.5% Okay, but still needless if data is eventable

TL;DR: If the source can be evented (file changes, network pushes, OS state), don’t poll. If you must poll, adaptively back off to minute-scale when idle.

Architecture: push first, poll as a dark corner

  • Backend (Rust) owns all I/O, watchers, and coalescing. It emits events to the frontend only when state actually changes.
  • Frontend (TS/React/Svelte) subscribes to events and does zero periodic work. No setInterval.
  • Streams: filesystem changes via notify, server push via WebSocket/SSE, OS tray/menu events via Tauri. Fallback HTTP polling is adaptive, jittered, and idles to minutes.

Key principles

  • Use event sources that the OS already schedules (kqueue/FSEvents/inotify/IOCP).
  • Batch and coalesce bursts; cap fan-out with bounded mpsc channels.
  • Prefer long-lived connections (WebSocket/SSE) over short-interval HTTP pulls.
  • Emit UI updates on the main thread but do the heavy lifting off-thread.

Code: a Tauri tray app wired for events

The example below shows:

  • System tray with click handlers
  • File watching without polling
  • WebSocket push with exponential backoff
  • Bounded, deduped event bus to the UI
// src-tauri/src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 

use std::{path::PathBuf, time::Duration};
use tauri::{Manager, SystemTray, SystemTrayEvent, SystemTrayMenu};
use tokio::{select, sync::mpsc, time::sleep};
use serde::Serialize;
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};

#[derive(Debug, Serialize, Clone)]
struct AppEvent {
  kind: String,
  payload: serde_json::Value,
}

type EventTx = mpsc::Sender<AppEvent>;

tauri::async_runtime::set(tokio::runtime::Handle::current());

#[tokio::main]
async fn main() {
  let tray_menu = SystemTrayMenu::new();
  let tray = SystemTray::new().with_menu(tray_menu);

  tauri::Builder::default()
    .system_tray(tray)
    .on_system_tray_event(|app, event| match event {
      SystemTrayEvent::LeftClick { .. } => {
        let _ = app.emit_all("tray://click", "left");
      }
      SystemTrayEvent::RightClick { .. } => {
        let _ = app.emit_all("tray://click", "right");
      }
      SystemTrayEvent::MenuItemClick { id, .. } => {
        let _ = app.emit_all("tray://menu", id.as_str());
      }
      _ => {}
    })
    .setup(|app| {
      let app_handle = app.handle();
      let (tx, mut rx) = mpsc::channel::<AppEvent>(128); // bounded for back-pressure

      // Spawn file watcher (event-driven)
      let tx_files = tx.clone();
      tauri::async_runtime::spawn(async move {
        if let Err(e) = file_watch_task(tx_files).await {
          eprintln!("file_watch_task error: {e:?}");
        }
      });

      // Spawn WebSocket push
      let tx_ws = tx.clone();
      tauri::async_runtime::spawn(async move {
        if let Err(e) = websocket_task(tx_ws).await {
          eprintln!("websocket_task error: {e:?}");
        }
      });

      // Optional: extremely slow health poll as last resort (coalesced)
      let tx_poll = tx.clone();
      tauri::async_runtime::spawn(async move {
        if let Err(e) = adaptive_poll_task(tx_poll).await {
          eprintln!("adaptive_poll_task error: {e:?}");
        }
      });

      // Drain events and emit to frontend (dedupe/coalesce if needed)
      tauri::async_runtime::spawn(async move {
        use std::collections::HashMap;
        let mut last_by_kind: HashMap<String, serde_json::Value> = HashMap::new();
        loop {
          if let Some(ev) = rx.recv().await {
            // Cheap dedupe: suppress repeats with same payload
            let changed = last_by_kind.get(&ev.kind) != Some(&ev.payload);
            if changed {
              last_by_kind.insert(ev.kind.clone(), ev.payload.clone());
              let _ = app_handle.emit_all(&ev.kind, ev.payload);
            }
          }
        }
      });

      Ok(())
    })
    .run(tauri::generate_context!())
    .expect("error while running tauri app");
}

async fn file_watch_task(tx: EventTx) -> anyhow::Result<()> {
  let path = config_dir();
  let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();

  // RecommendedWatcher uses native backends: FSEvents/kqueue, inotify, ReadDirectoryChangesW
  let mut watcher: RecommendedWatcher = Watcher::new_immediate(move |res| {
    let _ = notify_tx.send(res);
  })?;
  watcher.watch(&path, RecursiveMode::Recursive)?;

  // Debounce bursts (200ms window)
  let mut pending = false;
  loop {
    tokio::select! {
      Some(Ok(event)) = notify_rx.recv() => {
        if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)) {
          if !pending { pending = true; debounce_emit(&tx, "fs://config_changed").await?; }
        }
      }
      else => break,
    }
  }
  Ok(())
}

async fn debounce_emit(tx: &EventTx, kind: &str) -> anyhow::Result<()> {
  // Simple debounce: wait for more changes, then emit once
  tokio::spawn({
    let tx = tx.clone();
    let kind = kind.to_string();
    async move {
      sleep(Duration::from_millis(200)).await;
      let _ = tx.send(AppEvent { kind, payload: serde_json::json!({"ts": now()}) }).await;
    }
  });
  Ok(())
}

async fn websocket_task(tx: EventTx) -> anyhow::Result<()> {
  use tokio_tungstenite::connect_async;
  use tungstenite::protocol::Message;

  let mut backoff = 1u64; // seconds
  loop {
    match connect_async("wss://api.example.com/stream").await {
      Ok((ws, _resp)) => {
        backoff = 1; // reset
        let (mut write, mut read) = ws.split();
        // Optionally send auth
        let _ = write.send(Message::Text("{\"op\":\"hello\"}".into())).await;
        while let Some(msg) = read.next().await {
          match msg {
            Ok(Message::Text(txt)) => {
              let v: serde_json::Value = serde_json::from_str(&txt).unwrap_or_default();
              let _ = tx.send(AppEvent { kind: "push://update".into(), payload: v }).await;
            }
            Ok(Message::Ping(_)) => { /* tungstenite replies automatically */ }
            Ok(_) => {}
            Err(e) => { eprintln!("ws read error: {e:?}"); break; }
          }
        }
      }
      Err(e) => {
        eprintln!("ws connect error: {e:?}");
      }
    }
    // Exponential backoff with jitter, capped
    let delay = (backoff.min(60)) + fastrand::u64(0..3);
    sleep(Duration::from_secs(delay)).await;
    backoff = (backoff * 2).min(60);
  }
}

async fn adaptive_poll_task(tx: EventTx) -> anyhow::Result<()> {
  let mut interval = Duration::from_secs(60); // start conservative
  let max = Duration::from_secs(5 * 60);
  let min = Duration::from_secs(15);
  loop {
    if let Ok(Some(delta)) = cheap_etag_head().await { // None => no change
      let _ = tx.send(AppEvent { kind: "pull://delta".into(), payload: delta }).await;
      // Activity detected: temporarily tighten interval (but not below 15s)
      interval = (interval / 2).max(min);
    } else {
      // Idle: relax toward 5 minutes
      interval = ((interval.as_secs_f64() * 1.5) as u64).min(max.as_secs()).max(min.as_secs()).into();
    }
    sleep(interval).await;
  }
}

async fn cheap_etag_head() -> anyhow::Result<Option<serde_json::Value>> {
  let client = reqwest::Client::new();
  let resp = client
    .get("https://api.example.com/state")
    .header("If-None-Match", "\"cached-etag\"")
    .send().await?;
  if resp.status() == reqwest::StatusCode::NOT_MODIFIED { return Ok(None); }
  if resp.status().is_success() {
    let v: serde_json::Value = resp.json().await?;
    return Ok(Some(v));
  }
  Ok(None)
}

fn config_dir() -> PathBuf {
  tauri::api::path::config_dir().unwrap_or_else(|| std::env::current_dir().unwrap())
}

fn now() -> u64 { 
  std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64 
}

Frontend: subscribe, don’t poll

// src/main.ts
import { listen } from "@tauri-apps/api/event";

async function bootstrap() {
  // Tray/menu interactions
  await listen<string>("tray://click", ({ payload }) => {
    // Show a window or toggle state
    console.log("tray click:", payload);
  });
  await listen<string>("tray://menu", ({ payload }) => {
    console.log("menu id:", payload);
  });

  // Backend-driven updates
  await listen("fs://config_changed", () => reloadConfig());
  await listen("push://update", ({ payload }) => applyUpdate(payload));
  await listen("pull://delta", ({ payload }) => applyUpdate(payload));
}

function reloadConfig() { /* read from tauri command or local file */ }
function applyUpdate(payload: any) { /* update in-memory state and minimal UI */ }

bootstrap();

No timers. The UI only reacts to backend events.

Coalescing and back-pressure: keep wakeups low under burst

Event sources can burst (e.g., many small file writes). Strategies:

  • Bounded channels: the mpsc::channel(128) drops or back-pressures beyond 128 in-flight updates. For idempotent updates, dropping is fine.
  • Deduplication: suppress repeated payloads for the same kind.
  • Debounce: 100–300 ms window for filesystem bursts.

A more aggressive coalescer can use a keyed buffer that emits the latest value per key every N ms.

use tokio::time::{interval, Duration};
use std::collections::HashMap;

struct Coalescer {
  buf: HashMap<String, serde_json::Value>,
}

impl Coalescer {
  async fn run(mut self, mut in_rx: mpsc::Receiver<AppEvent>, out: tauri::AppHandle) {
    let mut tick = interval(Duration::from_millis(250));
    loop {
      select! {
        Some(ev) = in_rx.recv() => {
          self.buf.insert(ev.kind, ev.payload);
        }
        _ = tick.tick() => {
          for (k, v) in self.buf.drain() {
            let _ = out.emit_all(&k, v);
          }
        }
      }
    }
  }
}

Measuring the budget: trust but verify

Instrument locally and validate with OS tools.

  • In-process: sample process CPU and wakeups; on Linux use /proc, on macOS use host_statistics. Or use sysinfo crate to log CPU every 30s to a ring buffer.
  • macOS: sudo powermetrics --samplers tasks -i 1000 shows wakeups and C-state residency. Activity Monitor’s “Energy Impact” is a weak proxy; powermetrics is truth.
  • Windows: capture ETW (Windows Performance Recorder), analyze in WPA. Look at CPU Usage (Sampled), CPU Idle States, and Power->Energy usage.
  • Linux: powertop and perf stat -a -e power/energy-cores/ -p <pid>.

Aim for:

  • Wakeups/s near zero at idle (<< 0.1/s)
  • CPU around 0.5–1.8% depending on platform
  • No periodic network even when idle

Tauri-specific pitfalls to avoid

  • Don’t block the main thread. Heavy work must run in tauri::async_runtime::spawn tasks.
  • Don’t use frontend setInterval. All periodic work belongs in Rust and should be avoided anyway.
  • For filesystem changes, prefer RecommendedWatcher (native), not PollWatcher.
  • If you must poll HTTP, make it adaptive and ETag/If-None-Match based, with jitter and minutes-scale intervals.
  • Emit fewer, richer events instead of many fine-grained events.

What about cron-like schedules?

If you truly need a daily job, align with the OS and coalesce timers:

  • macOS: consider a launchd agent with StartInterval or a low-urgency DispatchSource timer in a helper.
  • Windows: use a Scheduled Task if the machine can be asleep; otherwise, a long tokio::time::sleep with process lifetime is fine. Avoid sub-minute repeating timers.

Results snapshot

On a representative build (Rust release, WebSocket idle, notify watcher active, no UI window):

  • macOS M2 Pro: ~0.6–0.9% CPU, ~0.02 wakeups/s
  • Windows 11 (12900H): ~0.8–1.4% CPU, wakeups near baseline
  • Linux (6.x kernel, Framework 13): ~0.7–1.2% CPU, powertop shows no additional timers

Adding a 5s polling loop alone pushed idle to ~1.3–1.8% on macOS and ~1.5–2.2% on Windows. Removing it and relying on evented sources pulled us back under 1% most of the time.

Checklist to hit ~1.8% idle

  • Replace polling with: file watchers (notify), push (WebSocket/SSE), OS events (tray/menu, power, network reachability).
  • Keep one long-lived socket; exponential backoff reconnects with jitter.
  • Bound and coalesce events; dedupe payloads.
  • No frontend timers. UI is purely reactive to backend emits.
  • Instrument CPU and wakeups; verify with powermetrics/ETW/powertop.

The outcome is a tray app that behaves like a good citizen: quiet at idle, instant on change, and well under a 1–2% CPU budget.

Looking to architect a similar system?

Let's ship it at AI-speed.

Start a conversation →