跪拜 Guibai
← All articles
Frontend

Runtime Performance Isn't About Faster Work: It's a Ledger of Who Runs First

By 前端阿凡 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Most teams treat runtime optimization as a vague effort to speed up work, but the models here show that the critical decisions are about which cost to pay and where to invest. The counting-budget interpretation of INP changes prioritization: fixing the most numerous just-over-threshold interactions beats fixing the single worst one, and the choice of yield primitive matters far less than actually reducing slice granularity. These are concrete, measurable trade-offs that any front-end team can apply to their own performance budgets.

Summary

This piece dismantles three common assumptions about runtime performance optimization. First, slicing a long task only helps when the work is not inside the handler of the interaction being measured: slicing a 1215ms hydration task cuts input delay from 1190.7ms to 49ms, but slicing a 120ms handler into 16 pieces adds 2.3ms of processing time. Second, INP is not the worst interaction; it's a high-percentile that, at 100 interactions, falls on the 3rd worst. Fixing the worst one changes INP from 506.3ms to 332.1ms because the 4th worst moves up. Third, the 50ms Long Task threshold is not the right budget for INP; the good threshold allows a slice granularity up to 126.2ms.

The article builds three zero-dependency Node scripts with 500 total invariant assertions. They establish closed-form formulas: mean input delay = g²/(2(g+o)), P98 input delay = 0.98g − 0.02o, wall-clock cost = W + (⌈W/g⌉−1)·o. The scripts show that setTimout's 4ms clamp makes it 26.7x more expensive than postTask for fine slicing, and that rAF costs jump from +0.14% at one-frame slices to +1566.7% at 1ms slices. Priority levels only buy queue length (40x speedup), not preemption, and marking everything user-blocking linearly destroys the benefit (100% inflation gives 410ms, nearly FIFO). The piece concludes with nine application scenarios and thirteen rules, emphasizing that the leader is the question: which segment (input delay, processing, presentation, or count-over-threshold) is being changed, and what is it exchanged for.

Takeaways
— Slicing a 1215ms startup hydration task cuts P98 input delay from 1190.7ms to 49ms; slicing a 120ms handler into 16 pieces adds 2.3ms of processing time.
— INP is a counting budget: with N interactions, up to floor(0.02N) can exceed 200ms. A session with 99 interactions under 149ms and one 5000ms disaster is good (148ms), while 97 at 60ms and three at 250ms is poor (250ms).
— The INP good threshold translates to a slice granularity g ≤ 126.2ms, not 50ms. A 100ms task is still a Long Task but does not break INP.
— setTimeout(fn, 0) suffers a 4ms nested-timer clamp; slicing the same 1215ms work to 1ms slices takes 6071ms with setTimeout vs 1397.1ms with scheduler.postTask.
— requestAnimationFrame slicing costs are stepwise: a slice exactly one frame long adds only 0.14% wall-clock, but a 1ms slice turns 1215ms into 20250ms because each slice waits one frame.
— Priority levels only buy queue length: correctly prioritizing discrete input over 40 background tasks cuts input delay from 400ms to 10ms, but a running 200ms synchronous task is not preempted.
— Priority inflation is linear: each 25% of tasks mislabeled user-blocking adds 100ms of input delay; labeling everything makes it equivalent to FIFO (410ms vs 400ms).
— Background task starvation is nonlinear: when user-blocking utilization rho rises from 0.7 to 0.95, completion time stretches from 3.3x to 20x.
— The three segments of interaction latency (input, processing, presentation) are additive; on a heavily-hydrated SPA, fixing only rendering scope yields just 5.8% INP improvement because input delay dominates 81.6%.
— await Promise.resolve() does not yield to the main thread; only macrotask boundaries allow input to be processed.
— Three scripts are provided: slice-budget.mjs, inp-anatomy.mjs, and scheduler-ledger.mjs, each zero-dependency and asserting against 500 total invariants.
— The parameter scan across 27 combinations of handler, presentation, and workload confirms the ordering conclusions hold for all parameter values; when handler+presentation consume 176.3ms, the remaining g budget is only 24.15ms.
Conclusions

The article's self-corrections are as valuable as its conclusions: the author initially asserted that longer gaps increase mean input delay, but the simulation showed the opposite because gaps are idle windows, which directly fed the key insight that INP, as a tail metric, is insensitive to gap length.

The counterintuitive example of a 5000ms catastrophe being rated good while three 250ms interactions are poor exposes a real gap in INP: it is a counting metric, not a severity metric, so it must be paired with LoAF or long-task monitoring to catch single-user disasters.

The practical lesson that primitive choice (postTask vs setTimeout) matters only when chasing zero-long-task or within-frame goals contradicts the common advice to switch to modern scheduling APIs; the real lever is reducing g, not picking a fancier yield.

The finding that fixing the worst interaction is often the wrong first step because the next-worst slides into place has broad implications for how performance budgets are allocated in teams.

The article's insistence on accounting discipline, asking which segment is being changed and what is exchanged, is a transferable debugging methodology for any performance optimization effort.

Concepts & terms
Slice granularity (g)
The longest chunk of main-thread work that runs without yielding. g determines the worst-case input blocking; reducing g is the only lever that improves the P98 tail of input delay. The INP good threshold corresponds to g ≤ 126.2ms under the article's model.
Yield cost (o)
The overhead of yielding the main thread between slices. o only affects the wall-clock penalty of slicing, not the tail latency. The closed-form g* = o/(M−1) shows that the finest possible slice for a given wall-clock budget multiplier M depends only on the yield primitive chosen.
INP counting budget
INP as a high-percentile (p98) metric allows at most floor(0.02N) interactions exceeding 200ms in a session of N interactions. This reframes optimization from reducing the worst-case duration to keeping the count of over-threshold interactions within the budget.
Task preemption
In the browser's main-thread scheduler, running tasks are not preemptible. Setting a task's priority only decides which task runs next, not how long the current one continues. Therefore priority cannot rescue a long synchronous task; only yielding to the scheduler can.
Priority inflation
When many tasks are mislabeled as user-blocking, they all queue in the same high-priority lane, effectively recreating FIFO order. The cost is linear: each 25% inflation adds about 100ms of input delay (under the article's model), and 100% inflation makes delays nearly equal to no priority at all.
Frame quantization floor
Presentation delay cannot go below one frame because the browser can only paint on frame boundaries. At P98, this adds 16.33ms, making the hard INP floor about 24.3ms even with zero rendering and an 8ms handler.
Timer clamping
HTML spec clamps nested setTimeout calls after the fifth level to a minimum delay of 4ms. This makes setTimeout(fn, 0) a poor choice for fine-grained slicing because it destroys duty cycle, especially for many small slices.
Continuation priority
scheduler.yield() resumes the yielded continuation at a higher priority, preventing other same-or-lower-priority queued tasks from interleaving. This keeps a chain of yields from being stretched by unrelated background tasks; without it, a 1215ms chain could become 6075ms when background utilization is 80%.
Starvation factor 1/(1−ρ)
Background tasks complete in W/(1−ρ) when user-blocking and user-visible tasks occupy ρ of the main thread. This is nonlinear: as ρ approaches 1, background work effectively never finishes. It highlights the need to budget user-blocking usage.
Source: juejin.cn ↗ Google Translate ↗ Backup ↗