---
name: yield-to-main-thread
description: "Audits and fixes JavaScript that blocks the main thread during clicks, taps and key presses: tasks over 50 ms, loops with no yield, scheduler.yield() that never pauses, no Safari fallback, forEach(async), setTimeout and idle-callback chunking. Runs a scanner and a Chromium probe, then edits flagged lines only. Use for poor INP, 'long task', 'UI freezes on click', scheduler.yield, Long Animation Frames. Do NOT use for debouncing, web workers, framework scheduling, LCP, CLS or field-data triage (use inp-optimisation)."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/javascript/yield-to-main-thread/
  category: frontend-development
  subcategory: javascript
  version: "1.0.0"
  created: "2026-11-07"
  updated: "2026-11-07"
---

# Yield to the Main Thread: Break Up Long Tasks

An agent works on long tasks in this order: confirm the slow part is handler code and not input delay or rendering, run the scanner, measure the real blocking time with the probe, choose a fix per case from the decision table, apply one yield helper with a fallback, guard against re-entry, then re-run the scanner and the probe. The scanner reads text and cannot time anything. Only the probe shows that a task is long.

Reusable code is in `references/patterns.md`. The probe, availability notes and the rival comparison are in `references/appendix.md`.

## When to use this skill, and when not to

Use it when a click, tap or key press feels slow and the handler or code it triggers runs for tens of milliseconds or more, when Lighthouse or DevTools reports long tasks, when `scheduler.yield()` is already in the code and the page still freezes, or when a loop over a large list locks the page.

| Request | Use instead |
|---|---|
| Search Console or CrUX flags poor INP and nobody knows which page or interaction | `inp-optimisation.md` (Marketing, Technical SEO) |
| Promise combinators, `forEach(async)` correctness, unhandled rejections | `promises-async-await.md` |
| Cancelling stale work when the user moves on | `abortcontroller-cancellation.md` |
| Debouncing or throttling input | Out of scope |
| Moving computation to a Web Worker | Out of scope |
| React, Vue or Angular scheduling features | Out of scope. Read that framework's documentation |
| Slow loading (LCP), layout shift (CLS), server response time | Out of scope |
| Layout thrashing and DOM size (presentation delay) | Out of scope |

## Guardrails

1. **Propose, then change.** Report first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Yielding changes behaviour.** Between two `await` points the user can click again and shared state can change. Never add a yield to code that must run as one unit (a payment, a form submit, a multi-field update) without the user confirming what a half-finished run should do.
3. **Never run the probe against a live production account.** Use a local build, a staging site or a test profile.
4. **Data handled.** The scanner prints file, line, code and a fixed message, never source text. The probe prints script URLs: strip query strings before pasting them anywhere (they can hold tokens or user ids). Never paste cookies, session storage or real user data into prompts or reports.
5. **Nothing is invented.** Every rule carries a source and a date. Browser support is **verify before relying on this**. Timings in reports come from a probe run, never from reasoning.
6. **Refuses to:** claim a task is long or fixed without a probe run, add a yield to every loop pass "to be safe", use `isInputPending` as the fix, or report an INP improvement from lab numbers alone.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Scope | "Which files or folders hold the slow interaction? Give paths." |
| Symptom | "Which click, tap or key press feels slow, and on which page?" |
| Field data | "Do you have an INP value at the 75th percentile for that page, or a slow interaction from a real-user tool? If not, say so." |
| Browsers | "Which browsers must work? Does Safari or Firefox matter?" |
| Run it | "Can I open the page in a test browser, or will you run the probe in DevTools and paste the table back?" |
| State | "Does the slow code write to shared state or the server? What should happen if the user clicks again while it runs?" |
| Framework | "Is there React, Vue, Svelte or Angular code in scope?" |
| Tests | "What command runs the tests, and is it safe to run here?" |
| Approval | "May I edit on a branch in batches and show each diff first?" |

## Workflow

### Step 1: Confirm it is a main-thread problem

Sources: web.dev, Interaction to Next Paint (updated 2 September 2025); web.dev, Optimize long tasks (updated 19 December 2024).

| Fact | Consequence |
|---|---|
| A task over 50 ms is a long task; blocking time is the duration minus 50 ms | 50 ms is the line every later step uses |
| INP is good at 200 ms or less, needs improvement up to 500 ms, poor above 500 ms, at the 75th percentile of page loads | Compare any number to these bands, not to a feeling |
| Clicks, taps and key presses count. Scrolling, hovering and zooming do not | Do not use this skill for a slow scroll |
| An interaction has three parts: input delay, processing duration and presentation delay | Decide which part is slow before editing |

| Slow part | Cause | Go to |
|---|---|---|
| Processing duration | Your event handlers and the code they call | Step 2 onward |
| Input delay | Other work already running when the user acts: a timer, a third-party script, start-up work | Step 3, then read `scripts[]` for the owner. If it is a third-party URL, the fix is not yielding in your code |
| Presentation delay | Rendering after the handlers: big DOM, forced layout | Out of scope |

### Step 2: Run the scanner

```
python3 scripts/long_task_check.py src | head -60
```

Read only its output. Do not read the script. The first line has the counts. `--strict` exits 1 on warnings. It skips `node_modules`, `dist`, `build`, `.git`, `.next` and `coverage`.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `yield-in-foreach` | error | A yield inside `forEach`, which does not wait for the callback | Step 5 |
| `yield-no-await` | warn | `scheduler.yield()` not awaited | Step 5 |
| `yield-no-fallback` | warn | `scheduler.yield` with no feature check | Step 5 |
| `input-pending` | warn | `isInputPending` in use | Step 6 |
| `innerhtml-append` | warn | `innerHTML +=`, which re-parses the subtree each time | Step 4 |
| `heavy-in-handler` | info | A handler with sort, JSON or nested-loop work and no yield | Step 3, then 4 |
| `yield-every-iteration` | info | A loop yields on every pass with no batch or time check | Step 5 |
| `timeout-recursion` | info | `setTimeout` calling its own function to chunk work | Step 5 |
| `idle-no-timeout` | info | `requestIdleCallback` with no `timeout` | Step 6 |

The scanner splits on lines and looks a few lines ahead. A hit inside a string or comment is a false positive: read the line. It cannot see a yield hidden in a helper function in another file, and it cannot tell a 1 ms loop from a 400 ms one.

### Step 3: Measure with the probe

Run the probe in `references/appendix.md` (section 1) on the slow interaction, in Chromium. Read only the table it prints.

| Probe result | Meaning | Action |
|---|---|---|
| `blocking` is 0 for every frame in the interaction | No long task in the lab run | Stop. Report "not reproduced". Do not edit |
| `blocking` above 0 and `invokerType` is `event-listener` or `user-callback` | Your handler code is long | Step 4 |
| `invokerType` is `resolve-promise` or `reject-promise` | A promise continuation is long | Step 4, find the awaited function |
| `invokerType` is `classic-script` or `module-script` | Load-time work | Out of scope |
| `sourceURL` is a third-party host | Not your code | Report it, do not edit |
| `forced` is a large share of `ms` | Layout thrashing | Out of scope |

Long Animation Frames are supported in Chrome and Edge 123 and later, not in Firefox or Safari (Chrome for Developers, updated 14 October 2024). **Verify before relying on this.** Where the probe cannot run, ask for a DevTools performance recording and read the longest tasks in it.

### Step 4: Choose the fix for each case

| Case | Fix |
|---|---|
| Only some of the work is needed for the next paint | Do the visible update first, `await yieldToMain()`, then the rest (`references/patterns.md`, section 3) |
| A loop over independent items | Time-budget batches, yield about every 50 ms (section 2) |
| One call that is long and cannot be split (a single `JSON.parse` of a huge string, one sort) | Yielding cannot help. Report it, and suggest a worker or a smaller payload. Out of scope |
| `innerHTML +=` in a loop | Build the markup once and assign once, or append with `replaceChildren`, then yield between batches if still long |
| Work nobody is waiting for (analytics, prefetch, cache warm-up) | Run it after the visible update with `scheduler.postTask` at `background` priority (**verify before relying on this** per browser), or `requestIdleCallback` with a `timeout` |
| Handler wraps several awaited calls, each long | Yield between the calls, not inside them |

The yield budget is 50 ms by default because web.dev uses a 50 ms deadline and describes it as a trade-off between responsiveness and total completion time. Total work time does not shrink: the first paint comes sooner and the rest finishes slightly later. Tell the user that.

### Step 5: Apply the yield helper

Source: web.dev, Optimize long tasks; MDN, Scheduler.yield(). Last verified 7 November 2026.

Use one shared helper, copied from `references/patterns.md` section 1:

```js
export const yieldToMain = () =>
  globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));
```

| Rule | Why |
|---|---|
| Always `await` it | `scheduler.yield()` only schedules the continuation; `await` is what pauses (web.dev) |
| Never inside `forEach`, `map` or other callbacks | Iterative array methods do not wait for each callback (web.dev). Use `for...of` |
| Keep the feature check | Safari has no `scheduler.yield` per web.dev. The fallback still yields but its continuation goes to the back of the queue |
| Yield in batches, not per item | Yielding after every tiny job adds overhead; yield after about 50 ms (web.dev) |
| Do not yield between two writes the user must see together | web.dev advises yielding before less critical work, not between critical UI updates |
| Inside `postTask`, the yield inherits priority and signal | MDN: it cannot be aborted on its own; yields inside `requestIdleCallback` callbacks are not abortable |

`scheduler.yield()` continuations run ahead of other tasks of the same priority; the WICG draft (30 May 2025) gives continuations a higher effective priority than tasks. A `setTimeout` continuation does not get that boost.

### Step 6: Replace the wrong schedulers

| Found | Problem | Replace with |
|---|---|---|
| `isInputPending()` | web.dev: it may return `false` after a user has interacted, and it is Chrome and Edge only; the author now recommends yielding regardless | Time-budget yield (step 5) |
| `setTimeout(fn, 0)` recursion | 5 ms minimum after five nested calls, and the task goes to the end of the queue (web.dev) | `await yieldToMain()` in a loop |
| `requestIdleCallback` for something the user clicked | Idle work can be delayed a long time on a busy page. This is an inference from its purpose, not a quoted rule | `await yieldToMain()` or `postTask` with `user-visible` priority |
| `requestIdleCallback` with no `timeout` | May not run at all while the page stays busy | Add `{ timeout }` with a value the user approves, or use `postTask` |
| `requestAnimationFrame` for slow work | It runs before paint, so slow work there delays the frame (inference) | Move the slow work out; keep rAF for the visual write |

### Step 7: Guard re-entry and stale state

After a yield the user can act again. Use the run counter in `references/patterns.md` section 4, or disable the control. Re-read shared state after every `await`. If the same flow also starts a request, pair this with `abortcontroller-cancellation.md` so the older run stops both its loop and its fetch.

### Step 8: Verify and report

1. Re-run the scanner. Errors must be 0. Each warning is fixed or listed with a reason.
2. Re-run the probe on the same interaction, same browser, same data size. Report before and after for `blocking` and for the click event `duration`.
3. Run the project's tests with the user's command. Report pass and fail counts, not output.
4. State what is untested: Safari and Firefox behaviour, slow devices, field INP.
5. Use the report template. Do not echo unchanged code back.

## Worked example

A test page with one click handler that blocks for 400 ms in a busy-wait loop, run in Chromium 153.0.8010.12 on 7 November 2026, five runs each, clicking with Playwright. The "after" version does the same 400 ms of work as eight 50 ms batches with `yieldToMain()` between them. The numbers are measured; the 400 ms work is synthetic and does not predict a real page.

| Version | Click event duration | Handler time | Largest `blockingDuration` |
|---|---|---|---|
| Before: one 400 ms task | 400 to 408 ms | 400 to 401 ms | 352 to 354 ms |
| After: 8 × 50 ms with yields | 56 to 64 ms | 50 to 51 ms | 2 to 3 ms |

A 400 ms click falls in the needs-improvement band (200 to 500 ms). INP is a field figure at the 75th percentile, so a lab click is not INP. The scanner on the matching source file:

```
$ python3 scripts/long_task_check.py before.js
1 errors, 4 warnings, 4 info, 1 files
before.js:4  info  heavy-in-handler   ...
before.js:8  warn  innerhtml-append   ...
before.js:13 error yield-in-foreach   forEach does not wait for async callbacks ...
before.js:15 warn  yield-no-fallback  scheduler.yield used with no feature check ...
before.js:17 warn  yield-no-await     scheduler.yield() not awaited ...
before.js:18 warn  input-pending      isInputPending is not recommended ...
```

(Messages shortened; three more info lines omitted.) The agent replaces the `forEach(async)` with `for...of`, adds the feature check through the shared helper, awaits the call, deletes the `isInputPending` branch and moves the `innerHTML +=` to one assignment. After the edit the scanner reports `0 errors, 0 warnings, 0 info`. The 400 ms of work still takes about 400 ms in total; the handler now returns control about every 50 ms, so a click on another control is handled within roughly one batch. That last sentence is an inference from the 56 to 64 ms click duration, not a measured second interaction.

## Templates

### Report

```
Long-task report: <repo or folder>, <date>
Interaction: <page and control>. Browsers: <list>. Tests: <command>.
Scanner before: N errors, N warnings, N info. After: N errors, N warnings, N info.
Probe before: click duration <ms>, blockingDuration <ms>. After: <ms>, <ms>. Browser <name and version>, data size <n>.
Changes: <file:line  code  old -> new>   (one line each)
Left as is: <file:line  reason>   (third-party script, single unsplittable call, load-time work)
Untested: Safari and Firefox, slow devices, field INP at p75 (edit to match)
```

### Starting prompt

```
Use the yield-to-main-thread skill. Scope: <paths>. Slow interaction: <page, control>.
Field INP at p75: <value or none>. Browsers: <list>. Can you open a test browser: <yes/no>.
Shared state or server writes in the slow code: <describe>. Tests: <command>.
Edit on a branch in batches of one file and show each diff first.
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| A yield that does not pause | `yield-no-await`, `yield-in-foreach`; the probe still shows the same `blockingDuration` | `await` it, in a `for...of` loop (step 5) |
| A yield on every item | `yield-every-iteration`; total run time grows and the page still stutters | Time-budget batches of about 50 ms (step 5) |
| No fallback | `yield-no-fallback`; the handler throws in a browser without the method | One shared helper with the feature check (step 5) |
| Yielding into a stale or double run | Duplicate rows, a spinner that never clears, two runs interleaved | Run counter or disabled control; re-read state (step 7) |
| The wrong scheduler | `input-pending`, `timeout-recursion`, `idle-no-timeout`; work resumes late or never | Step 6 table |

## Quality bar

Run every item before handing over.

1. `long_task_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. The probe was run before and after on the same interaction, and the report states `blockingDuration` and click duration for both.
3. No edit was made where the probe showed `blocking` of 0, a third-party `sourceURL` or a load-time script.
4. Every `scheduler.yield` goes through one helper with a feature check and is awaited outside any array callback.
5. No loop yields per item without a batch or time check.
6. Every yielding handler has a run counter or a disabled control, and the user approved the half-finished behaviour.
7. The report lists what was not tested, including Safari, Firefox, slow devices and field INP.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages read on 7 November 2026.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 104 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 5,018 | Estimate | When the skill fires |
| C. `references/patterns.md` | 887 | Estimate | Step 4 or 5 for code, step 7 for the run counter, step 8 for the test |
| C. `references/appendix.md` | 1,224 | Estimate | Step 3 for the probe; otherwise only for availability, scheduler choice or the rival comparison |
| Script output (typical run) | 418 | Estimate | Scanner output measured on the matching fixture |
| D. Typical run (A + B + probe from the appendix + script output) | 5,783 | Estimate | Common path |
| D. Worst case (A + B + both references + 40 lines of output) | 8,906 | Estimate | Large input, every branch |
| E. Cheaper path | 5,783 | Estimate | Run the scanner, fix only flagged lines, skip the appendix except the probe |

The rivals read have bodies of about 160 to 2,160 tokens, and none has a scanner or a probe. This skill costs more to load than two of them. In return the agent reads the scanner output instead of every file, and measures instead of guessing.

## Handoffs

`inp-optimisation.md` covers field data, Search Console and platform fixes. `promises-async-await.md` covers combinators and `forEach(async)` correctness. `abortcontroller-cancellation.md` covers stopping stale runs. This skill covers only how to hand the main thread back from your own JavaScript.

## Appendix

The probe, availability table, scheduler table and rival comparison are in `references/appendix.md`. Reusable code is in `references/patterns.md`. Read them only when a step points there.

## Sources

Last verified 7 November 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- web.dev, Optimize long tasks (updated 19 December 2024): https://web.dev/articles/optimize-long-tasks
- web.dev, Interaction to Next Paint (updated 2 September 2025): https://web.dev/articles/inp
- MDN, Scheduler: yield() method: https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/yield
- Chrome for Developers, Long animation frames API (updated 14 October 2024): https://developer.chrome.com/docs/web-platform/long-animation-frames
- WICG, Prioritized Task Scheduling, Draft Community Group Report (30 May 2025): https://wicg.github.io/scheduling-apis/
- addyosmani, web-quality-skills, core-web-vitals. Rival implementation: https://raw.githubusercontent.com/addyosmani/web-quality-skills/main/skills/core-web-vitals/SKILL.md
- thedaviddias, Front-End-Checklist, scheduler-yield. Rival implementation: https://raw.githubusercontent.com/thedaviddias/Front-End-Checklist/main/skills/scheduler-yield/SKILL.md
- xenitv1, claude-code-maestro, optimization mastery. Rival implementation: https://lobehub.com/skills/xenitv1-claude-code-maestro-optimization-mastery

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/javascript/yield-to-main-thread/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.
