JavaScript skill 3 of 12

Yield to Main Thread SKILL.md: Fix Long Tasks and Slow Clicks

The Yield to Main Thread skill makes Claude or Cursor find and fix long tasks that freeze a page on click: loops with no yield, scheduler.yield() that never pauses, missing Safari fallbacks and the wrong scheduler. A scanner flags each pattern, a Chromium probe measures blocking time, and the agent edits only flagged lines in batches you approve.

yield-to-main-thread/SKILL.md, version 1.0.0, 3,478 words, plus scripts/long_task_check.py and two reference files.

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.

RequestUse instead
Search Console or CrUX flags poor INP and nobody knows which page or interactioninp-optimisation.md (Marketing, Technical SEO)
Promise combinators, forEach(async) correctness, unhandled rejectionspromises-async-await.md
Cancelling stale work when the user moves onabortcontroller-cancellation.md
Debouncing or throttling inputOut of scope
Moving computation to a Web WorkerOut of scope
React, Vue or Angular scheduling featuresOut of scope. Read that framework's documentation
Slow loading (LCP), layout shift (CLS), server response timeOut 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.

InputAsk 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?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as yield-to-main-thread/SKILL.md.

---
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.

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).

FactConsequence
A task over 50 ms is a long task; blocking time is the duration minus 50 ms50 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 loadsCompare any number to these bands, not to a feeling
Clicks, taps and key presses count. Scrolling, hovering and zooming do notDo not use this skill for a slow scroll
An interaction has three parts: input delay, processing duration and presentation delayDecide which part is slow before editing
Slow partCauseGo to
Processing durationYour event handlers and the code they callStep 2 onward
Input delayOther work already running when the user acts: a timer, a third-party script, start-up workStep 3, then read scripts[] for the owner. If it is a third-party URL, the fix is not yielding in your code
Presentation delayRendering after the handlers: big DOM, forced layoutOut 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.

CodeLevelMeaningFix
yield-in-foreacherrorA yield inside forEach, which does not wait for the callbackStep 5
yield-no-awaitwarnscheduler.yield() not awaitedStep 5
yield-no-fallbackwarnscheduler.yield with no feature checkStep 5
input-pendingwarnisInputPending in useStep 6
innerhtml-appendwarninnerHTML +=, which re-parses the subtree each timeStep 4
heavy-in-handlerinfoA handler with sort, JSON or nested-loop work and no yieldStep 3, then 4
yield-every-iterationinfoA loop yields on every pass with no batch or time checkStep 5
timeout-recursioninfosetTimeout calling its own function to chunk workStep 5
idle-no-timeoutinforequestIdleCallback with no timeoutStep 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 resultMeaningAction
blocking is 0 for every frame in the interactionNo long task in the lab runStop. Report "not reproduced". Do not edit
blocking above 0 and invokerType is event-listener or user-callbackYour handler code is longStep 4
invokerType is resolve-promise or reject-promiseA promise continuation is longStep 4, find the awaited function
invokerType is classic-script or module-scriptLoad-time workOut of scope
sourceURL is a third-party hostNot your codeReport it, do not edit
forced is a large share of msLayout thrashingOut 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

CaseFix
Only some of the work is needed for the next paintDo the visible update first, await yieldToMain(), then the rest (references/patterns.md, section 3)
A loop over independent itemsTime-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 loopBuild 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 longYield 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:

export const yieldToMain = () =>
  globalThis.scheduler?.yield ? scheduler.yield() : new Promise((r) => setTimeout(r, 0));
RuleWhy
Always await itscheduler.yield() only schedules the continuation; await is what pauses (web.dev)
Never inside forEach, map or other callbacksIterative array methods do not wait for each callback (web.dev). Use for...of
Keep the feature checkSafari 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 itemYielding after every tiny job adds overhead; yield after about 50 ms (web.dev)
Do not yield between two writes the user must see togetherweb.dev advises yielding before less critical work, not between critical UI updates
Inside postTask, the yield inherits priority and signalMDN: 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

FoundProblemReplace 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 regardlessTime-budget yield (step 5)
setTimeout(fn, 0) recursion5 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 clickedIdle work can be delayed a long time on a busy page. This is an inference from its purpose, not a quoted ruleawait yieldToMain() or postTask with user-visible priority
requestIdleCallback with no timeoutMay not run at all while the page stays busyAdd { timeout } with a value the user approves, or use postTask
requestAnimationFrame for slow workIt 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.

VersionClick event durationHandler timeLargest blockingDuration
Before: one 400 ms task400 to 408 ms400 to 401 ms352 to 354 ms
After: 8 × 50 ms with yields56 to 64 ms50 to 51 ms2 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

MistakeHow to spot itDo instead
A yield that does not pauseyield-no-await, yield-in-foreach; the probe still shows the same blockingDurationawait it, in a for...of loop (step 5)
A yield on every itemyield-every-iteration; total run time grows and the page still stuttersTime-budget batches of about 50 ms (step 5)
No fallbackyield-no-fallback; the handler throws in a browser without the methodOne shared helper with the feature check (step 5)
Yielding into a stale or double runDuplicate rows, a spinner that never clears, two runs interleavedRun counter or disabled control; re-read state (step 7)
The wrong schedulerinput-pending, timeout-recursion, idle-no-timeout; work resumes late or neverStep 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.

ItemTokensStatusRead condition
A. Description (always loaded)104EstimateEvery session with the skill installed
B. SKILL.md body5,018EstimateWhen the skill fires
C. references/patterns.md887EstimateStep 4 or 5 for code, step 7 for the run counter, step 8 for the test
C. references/appendix.md1,224EstimateStep 3 for the probe; otherwise only for availability, scheduler choice or the rival comparison
Script output (typical run)418EstimateScanner output measured on the matching fixture
D. Typical run (A + B + probe from the appendix + script output)5,783EstimateCommon path
D. Worst case (A + B + both references + 40 lines of output)8,906EstimateLarge input, every branch
E. Cheaper path5,783EstimateRun 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.

What this skill does

A slow click is often one long task. A handler that sorts, parses and renders in a single pass keeps the main thread busy, and the browser cannot paint or answer another input until it ends. web.dev defines a long task as anything over 50 ms. Fixes that look right often are not: scheduler.yield() without await only schedules, a yield inside forEach never pauses the loop, and isInputPending can report no pending input after a user has acted.

The skill first checks whether the slow part is handler code, input delay or rendering. It then runs scripts/long_task_check.py, measures real blocking time with a Long Animation Frames probe in Chromium, and applies one shared yield helper with a feature check, in batches of about 50 ms. It adds a guard against a second click during paused work, replaces setTimeout recursion and unguarded idle callbacks, and refuses to edit where the probe shows no blocking or a third-party script.

Who it is for

Use it if you are a frontend developer, a reviewer of AI-generated handlers, or the owner of a filter, table, menu or form that stalls when someone clicks. It fits 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 big list locks the page. It does not cover debouncing, Web Workers, framework schedulers, loading speed or Search Console triage.

What is inside

The file is 3,478 words (version 1.0.0) with one script and two reference files. It holds:

  • Six guardrails, including propose-then-change, no yields in code that must run as one unit, no probing of live accounts and no INP claims from lab numbers.
  • Nine inputs the agent must have first, with the exact question for each, including whether it may open a test browser.
  • A workflow of eight steps: confirm the slow part, scan, probe, choose a fix, apply the helper, replace wrong schedulers, guard re-entry, verify.
  • One tool, scripts/long_task_check.py, a standard-library scanner with nine codes that prints file, line and a fixed message but never source text.
  • A worked example with measured Chromium numbers: a 400 ms click became 56 to 64 ms.
  • A report template, a starting prompt, five failure modes and a seven-item quality bar.
  • Two reference files read only when a step points to them: reusable code, and the probe with availability notes and the rival comparison.
ItemTokensStatusRead when
Description104EstimateEvery session with the skill installed
SKILL.md body5,018EstimateWhen the skill fires
references/patterns.md887EstimateSteps 4, 5, 7 and 8, for code, the run counter or the test
references/appendix.md1,224EstimateStep 3 for the probe; otherwise only for availability notes or the rival comparison
Typical run5,783EstimateDescription, body, probe and scanner output
Worst case8,906EstimateBoth references and 40 lines of output
Cheaper path5,783EstimateRun the scanner, fix flagged lines, use only the probe from the appendix

The figures come from the skill’s own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. The three rivals I read have bodies of about 160 to 2,160 tokens by the same method and none has a scanner or a probe.

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as yield-to-main-thread/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the script beside your code. The scanner needs Python 3 and nothing else. The probe runs in any Chromium DevTools console. Then send the starting prompt from the Templates section.

Before you install. Read the script first. It reads files and writes nothing. The skill tells the agent to edit only on a branch in batches you approve, never to probe a live account, and never to add a yield to code that must run as one unit without your say-so. Install skills only from sources you trust.

A worked example

A test page has one click handler that blocks for 400 ms. In Chromium 153, five runs, the click took 400 to 408 ms and the largest blockingDuration was 352 to 354 ms. The same work as eight 50 ms batches with a yield between them gave a click of 56 to 64 ms and a blocking duration of 2 to 3 ms. The 400 ms of work still takes about 400 ms in total; the first paint comes sooner.

On a matching source file the scanner prints 1 error and 4 warnings, including forEach(async) with a yield, a yield with no feature check, an un-awaited yield and isInputPending. After the edit it prints 0 errors, 0 warnings and 0 info. The numbers come from a synthetic busy-wait, so they show the mechanism, not the gain on your page.

Common mistakes

The skill is built around five ways this goes wrong:

  • A yield that does not pause. No await, or a yield inside forEach. The skill awaits it inside a for...of loop.
  • A yield on every item. web.dev warns about the overhead. The skill yields in batches of about 50 ms.
  • No fallback. web.dev lists Safari as unsupported. The skill uses one helper with a feature check.
  • Yielding into a stale or double run. A second click can start while work is paused. The skill adds a run counter.
  • The wrong scheduler. isInputPending, setTimeout recursion and idle callbacks with no timeout. The skill replaces each.

INP Optimisation covers field data, Search Console and platform fixes, and is where a poor INP report starts. Promises and Async/Await covers forEach(async) correctness and unhandled rejections. AbortController Cancellation covers stopping the stale run when a user clicks again.

Frequently asked questions

The method pauses a function and resumes it in a new task, so the browser can handle input and painting in between. Write await scheduler.yield() inside an async function; without await nothing pauses, per web.dev. MDN lists it as limited availability, so keep a fallback. The skill gives you one helper that does this.

web.dev defines a long task as any task that takes longer than 50 milliseconds, and the blocking time is the duration minus 50 ms. A Long Animation Frame is a frame where rendering is delayed past 50 ms. The skill uses 50 ms as the line for every fix and for the batch size.

Not according to web.dev, which lists Chrome, Edge and Firefox 142 as supported and Safari as not. MDN marks the method as limited availability. Check the MDN table before relying on this. The skill ships a fallback that yields with setTimeout, with the trade-off that the continuation goes to the back of the queue.

First confirm the delay is in your handler and not in input delay or rendering. Then split the work into batches of about 50 ms with a yield between them, and show the visible update first. The skill runs a scanner, measures blocking time with a probe, and edits only flagged lines.

Usually the yield is inside forEach, which does not wait for async callbacks, or it runs after every tiny job and adds overhead, as web.dev warns. It can also be one unsplittable call, such as a huge JSON.parse. The scanner flags the first two, and the probe shows the third.

For pausing inside a task, scheduler.yield() is better where it exists, because the continuation is prioritised. web.dev notes that setTimeout waits for a minimum of 5 ms after five nested calls and goes to the back of the queue. Idle callbacks suit work nobody waits for. The skill has a table.

The description is about 104 tokens and loads in every session. The SKILL.md body is about 5,018 tokens when the skill fires. A typical run is about 5,783 tokens with the probe and scanner output, the cheaper path is the same, and the worst case with both references is about 8,906. All figures are estimates.

Changelog

  • 1.0.0, 7 November 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.