JavaScript performance guide (v0)
Stable shapes, fewer per-iteration allocations — plus a separate chapter on tooling and cold-start.
Focus: modern Node and V8 (inline caches / GC). QuickJS and 小程序-like embeds get their own chapter (§3) — skip advice that needs a DOM or a full browser profiler. §4 is V8-only (maps, elements kinds, deopts, Ignition → Maglev/Turbofan) and does not apply to §3 runtimes. §5 covers startup and tooling (module resolution, eager graphs, barrels) after Marvin Hagemeister’s ecosystem series; that cost sits outside the jp-* lint rules.
Speedup claims cite a local bench, an upstream source, or are marked provisional. Corpus residual counts are lint hit counts, not wall-time savings. §4.6 cites single-machine microbench medians only. §5 cites Marvin’s methods (URLs); re-measure his timings locally.
Companion:
jsperf-lint/— the jp-* rules, see the Lint map (line windows; not a full parser). Run against Nodelib/, Preactsrc/, quickjs*.js(tests skipped), and TypeScriptpackages/typescript/src(2026-10-21 Asia/Shanghai). See corpus.md. Startup and FS cost are out of band for the jp-* rules — see §5.
Profile first (node --prof, node --cpu-prof + speedscope, Chrome Performance, clinic). Prefer consistent object shapes and batch allocation over micro-optimizing a single property access. On embeds / 小程序, skip DOM tips and do not apply Turbofan/Maglev advice. On Node/Chrome, use §4 for elements kinds, IC polymorphism, and deopt checks. For CLIs, lint/test runners, and package cold-start, use §5.
Contents
- 0. Scope and method
- 1. Allocation and shapes
- 2.
deleteand shape - 3. Embed and 小程序
- 4. V8 optimizations (Node / Chrome)
- 5. Startup and tooling (cold paths)
- 6. Constructors, JSON, DOM queries, and date formats
- Lint map
- References
0. Scope and method
- Shapes first — initialize fields in one order; avoid polymorphic hot paths (§1, §4.1).
- Alloc pressure —
.push({…}),new, non-empty object binders; default scope reports these outside loops too (§1). delete— prefer tombstones or parallel structures on hot objects (§2); avoiddelete arr[i]holes (§4.2,jp-delete-array-hole).- Embed — same alloc/shape rules without assuming
window/ DOM or V8 tiers (§3). - V8 on Node/Chrome — elements kinds, IC state, deopts, pipeline sketch (§4).
- Startup / tooling — profile with
--cpu-prof; cut FS/resolution; lazy-load rare paths; watch barrels (§5). - Measure —
--prof/--cpu-prof/--trace-deopt/ Performance panel /examples/benchbefore rewriting.
Sources: XPerf playbook · corpus.md · Marvin series
1. Allocation and shapes
1.1 Hidden classes (shapes)
Engines (V8 and friends) assign a hidden class / map based on property addition order. Divergent shapes in a hot loop force polymorphic or megamorphic ICs.
- Initialize all fields in a consistent order (
return { x, y, z: 0 }). - Avoid
ifbranches that add different properties first. - See
examples/m01_hidden_class.js(illustrative; no timing claim).
1.2 Allocation in loops (jp-alloc-hot)
Per-iteration allocation creates garbage and GC pauses when the loop is hot:
| Pattern | Lint? | Notes |
|---|---|---|
.push({…}) / .push([…]) | yes | Classic builder noise |
Non-empty {…} / […] binder in a loop | yes | Options bags that escape each iter |
new Ctor / Object.create in a loop | yes (r2+) | Except throw new / Error / ERR_* |
Empty {} / [] binder | no (r2) | Once-per-retry opts bags were noise on Node |
throw new … | no | Error path |
Prefer typed arrays (examples/p01_typed_array.js), object pools, or preallocated structures when profiles show GC time — not by default on every .push.
Evidence (this machine): evidence/jp-alloc-hot — new Uint8Array in-loop vs once ~19×; .push({i}) vs prealloc fill ~6.4× (Node v20.19.2). Mechanism is alloc/GC (STATIC-VS-V8 PARTIAL for deopt). Default severity is off (--preset all or --severity jp-alloc-hot=hint enables it) — see evidence-policy. Default --scope all reports these patterns outside loops too; --scope loop keeps the former in-loop-only match.
1.3 Corpus hit counts
Counts below come from earlier line-window revisions of the linter (r1–r3) and show how the heuristics were narrowed; current corpus results are in corpus.md.
| Corpus | r1 → r2 → r3 | Notes |
|---|---|---|
Node lib/ | 60 → 179 → 115 (jp-alloc-hot 33 → 158 → 94; jp-delete-shape 27 → 21 → 21) | r2 intentional new / non-empty binders raised jp-alloc-hot; r3 comment/for FP cut it. Residual jp-alloc-hot: inspect.js new SafeMap() (~16), stream pull.js / share.js / broadcast.js IterResult / Uint8Array, abort_controller.js ??= new SafeSet() inside a real for |
Preact src/ | 0 → 0 → 0 | Clean baseline — rules may be narrow for framework cores (little .push({ / loop new) |
quickjs *.js | 5 → 4 → 2 jp-alloc-hot | tests/ skipped; leftover codegen-ish scripts |
TypeScript packages/typescript/src | — → 47 → 15 jp-alloc-hot | Post-Go-migration tip (~50 .ts); comment/loop fix helped. Classic src/compiler not in this tree |
Reading Node residual: once-init ??= new inside a real loop is often true allocation (severity tier vs .push({ still open). inspect.js SafeMap construction can be loop-adjacent without being per-element hot — review, do not mass-rewrite.
1.4 Reverse-opt that mattered
- Drop empty
{}/[]binders (r2) — killed Node options-bag noise; kept.push({. - Multi-line block comments + brace-less
forfix (r3) — JSDocforandfor (…)\n stmt;no longer open fake loops (Console prototype install, etc.). - Fixtures:
good/empty_opts_in_loop.js,good/comment_for_not_loop.js,good/throw_new_in_loop.js,good/jp-alloc-hot_reuse_buffer.js,bad/jp-alloc-hot_new_in_loop.js,bad/jp-alloc-hot_push_object.js.
1.5 String += in a loop (removed)
jp-string-plus-in-loop used to match += of a string literal or String.fromCharCode and say to switch to push then join. That rewrite does not have about a 2× gain on the shapes the matcher hit (Node v24.18.0, V8 13.6.233.17-node.50).
| Shape | += ms | join ms | += / join |
|---|---|---|---|
"\n" × 100, repeated 200 | 0.194 | 0.126 | 1.53 |
"\n" × 22000, repeated 20 | 1.648 | 2.756 | 0.60 |
"\n" × 44000, repeated 10 | 1.459 | 3.176 | 0.46 |
String.fromCharCode × 22000, repeated 10 | 0.893 | 2.647 | 0.34 |
| 64-character literal × 700, repeated 40 | 0.070 | 0.226 | 0.31 |
obj.s += "\n" × 22000, repeated 10 | 0.759 | 1.816 | 0.42 |
A ratio near 2 would mean join is twice as fast. The 100-newline case is 1.53×, under that bar. At 22000 and 44000 characters, join is slower. V8 already builds the string for += in a loop. push then join also allocates the pointer array. The matcher is deleted. Tombstone: jp-string-plus-in-loop.
1.6 Chained .map / .filter (jp-map-filter-chain)
.filter().map() allocates an intermediate array on every call. The cost grows with the array length and with how many passes you make. A 3-element list and a 35000-row table are not the same cost.
On this machine (same Node as §1.5), both sides build the result array:
| Shape | chain ms | one pass ms | ratio |
|---|---|---|---|
| 3 rows, filter then map, × 20000 | 0.289 | 0.226 | 1.28× |
| 3 rows, four filters then map, × 20000 | 0.761 | 0.220 | 3.46× |
| 35000 rows, filter then map, × 20 | 8.728 | 1.898 | 4.60× |
| 35000 rows, four filters then map, × 20 | 12.850 | 1.306 | 9.84× |
The 3-row filter-then-map case is under 2×. Four passes on 3 rows is above 2×, and 20000 calls still finish in under a millisecond. The 35000-row cases are where one pass is clearly faster. The hint names that length. Rewriting a short list is optional.
Lint: jp-map-filter-chain — default hint. // comments and block comments are stripped before the match. Codegen gen-*.js files are path-skipped. Evidence: jp-map-filter-chain.
2. delete and shape
2.1 Why delete hurts on hot objects
delete obj.key changes the object’s hidden class. Engines may fall off monomorphic ICs; the hole can force dictionary mode on some paths. Prefer:
obj.key = undefined/nullwhen the field is optional and the shape should stay stable.- A parallel
Set/ bitset / dense side table for “present?” when absence is common. - Rebuild a new object in a cold path instead of deleting in a hot loop.
Lint: jp-delete-shape (delete on a member expression; file-level heuristic, not loop-only by default). Measured gate: evidence/jp-delete-shape (~474× vs tombstone on Node v20.19.2); the JSONL agrees, but corpus triage found the hits were almost all OK-idiom (see evidence-policy), so the default is off (--preset all or --severity jp-delete-shape=error enables it).
2.2 Intentional delete (bootstrap / feature stripping)
Node’s lib/internal/bootstrap/** uses delete for module cache and feature stripping. That is startup / one-shot, not a per-request hot path.
jsperf-lint: skips files under internal/bootstrap/, filenames matching *bootstrap*, and inspect/debugger/test-runner paths for jp-delete-shape and jp-delete-array-hole.
Corpus hits: Node lib/ jp-delete-shape 27 → 21 after bootstrap skip (r1→r2); held at 21 in r3 while jp-alloc-hot moved. Remaining deletes need case-by-case review (CLI teardown, rare maps).
2.3 Optional future
--hot-only (require loop context for jp-delete-shape) is not shipped. Until then, treat jp-delete-shape outside loops as a shape smell, not an automatic bug.
3. Embed and 小程序
3.1 Same physics, smaller runtime
QuickJS, Hermes-like embeds, and 小程序 JS runtimes still pay for:
- Per-iteration object allocation (jp-alloc-hot patterns).
- Shape changes via
deleteor divergent property order. - Logging in tight loops (
jp-console-hot—console.log/console.debug). Evidence:jp-console-hot(~1300× vs quiet even with discarded stdout) — default off (app) / hint (engine), not a CI error.
They often lack a browser Performance panel, DOM, or large IC budgets. Advice that starts with “open DevTools → …” does not transfer. Prefer:
- Alloc/shape discipline from §1–§2.
- Batching work (
examples/p02_batch_note.js) instead of per-item microtasks when the host exposes a batch API. - Measuring with the host profiler (Node
--prof, QuickJS timing hooks, vendor 小程序 perf panels) — not Chrome-only workflows.
Do not apply §4 (Maglev/Turbofan, elements-kind transitions, --trace-deopt) to these runtimes. Stable shapes and fewer allocations still help; V8 tier talk does not.
3.2 Shared quickjs tree
cperf covers the C engine; jsperf covers the *.js beside it (tests and gen-* skipped). The current rules report 0 findings there (corpus.md); earlier revisions reported 2 jp-alloc-hot. Low pressure, but useful as an embed-adjacent sample. Do not expect Preact-level cleanliness on generator scripts.
3.3 What not to recommend here
- DOM batching /
requestAnimationFrameas the primary fix. innerHTMLvscreateElementdebates.- Browser-only APIs (
WeakRef+ FinalizationRegistry patterns that the embed may not ship). - Turbofan / Maglev / elements-kind tuning from §4.
Typed arrays and consistent shapes remain portable (examples/p01_typed_array.js, m01_hidden_class.js).
4. V8 optimizations (Node / Chrome)
This chapter is V8-only. It does not apply to QuickJS, Hermes-like embeds, or 小程序 runtimes — see §3. Those engines still benefit from stable shapes and fewer allocations; they do not get Ignition → Maglev/Turbofan advice, elements-kind transitions, or --trace-deopt workflows.
Measured on Node v22.22.3 (V8 12.4.x). Pipeline tier names and flags drift; treat the pins below as what we ran, not forever guarantees.
4.1 Hidden classes / maps and ICs (extends §1.1)
V8 attaches a map (hidden class) to each object. Property addition order and type feedback build a transition tree. Property loads and stores go through inline caches (ICs):
| IC state | Meaning (practical) |
|---|---|
| Monomorphic | One map seen — best case for a hot load/store |
| Polymorphic | A few maps (small fixed set) — still optimizable, slower |
| Megamorphic | Many maps — IC gives up; generic slow path |
Do: construct with one field order ({ x, y, z: 0 }); keep hot call sites on one shape. Don’t: branch so some objects get x before y and others the reverse; don’t delete fields on hot objects (§2).
Microbench: examples/bench/shape_mono_vs_poly.js (same-order vs divergent constructors; numbers in §4.6).
4.2 Elements kinds
Arrays are not one representation. V8 tracks elements kinds, roughly:
| Kind (concept) | Typical contents |
|---|---|
PACKED_SMI_ELEMENTS | Dense small integers, no holes |
PACKED_DOUBLE_ELEMENTS | Dense floats (after a non-Smi number appears) |
PACKED_ELEMENTS | Dense object/value mix |
HOLEY_* | Same families with holes (missing indices) |
Practical dos/don’ts:
- Prefer dense
push/ preallocate (new Array(n)then fill 0..n-1) over sparse writes. - Don’t punch holes:
delete arr[i], or skip indices (arr[0]=1; arr[2]=2), forces holey kinds and weaker optimized code. - For numeric hot loops, prefer typed arrays (
Float64Array,Uint32Array, …): fixed element type, no holey transition, contiguous storage. Ordinarynumber[]can stay packed, but mixed Smi/double transitions and boxing still cost. - Reading past
lengthor relying on holes as “optional slots” is a shape smell; use a parallel length, a typed bitmask, orundefinedtombstones on a packed array if you must.
Lint: jp-delete-array-hole flags delete arr[i]-style member deletes (holey risk); measured gate evidence/jp-delete-array-hole (~3.2×). jp-delete-shape covers delete obj.key on ordinary members; it historically missed computed index deletes — jp-delete-array-hole closes that gap.
Microbenches: examples/bench/elements_packed_vs_holey.js, examples/bench/typed_array_vs_number_array.js.
4.3 Deopts — when they matter; how to check
A deopt throws optimized code back to a slower tier because assumptions broke (unexpected map, elements kind, bounds, etc.). Occasional deopts at startup or on rare paths are normal. Deopts matter when they sit on a profiled hot function and repeat every iteration.
Static jsperf-lint cannot detect JIT outcomes. IC state, deopt, and optimization tier are runtime facts. The jp-* rules only flag allocation/shape smells. JIT verification = profile / --trace-deopt / bench — never “the linter said so.”
Runtime harness (not a lint rule):
# Preferred across Node versions:
node --trace-deopt examples/bench/deopt_probe.js
bash scripts/jit-check.sh # writes examples/bench/out/deopt-sample.txt
# Optional / fragile: V8 natives (status codes drift)
USE_NATIVES=1 node --allow-natives-syntax --trace-deopt examples/bench/deopt_probe.jsOn this machine (Node v22.22.3), deopt_probe warmed hotLoad monomorphic then fed divergent maps; --trace-deopt reported:
[bailout … reason: wrong map … JSFunction hotLoad … Code TURBOFAN]
One-shot -e scripts often also print “Insufficient type feedback …” — treat that as startup noise, not proof the smell is hot.
Check (Chrome): Performance panel → V8 markers where available; correlate with wall-clock before/after. No fake speedup percentages.
4.4 Pipeline sketch (Ignition → Maglev / Turbofan)
High level, modern V8/Node:
- Ignition — bytecode interpreter; gathers type feedback.
- Maglev — mid-tier optimizing compiler (present on current Node 22+ / recent V8 builds; exact enablement can depend on flags and workload).
- Turbofan — top-tier optimizing compiler for hot code.
Code may climb tiers as it heats up and fall back on deopt. Do not claim “always Maglev on Node N” or pin exact tier thresholds — they move. For embeds without this pipeline (§3), skip tier talk entirely.
4.5 What jsperf-lint can and cannot see
Hard limit: the static jp-* rules cannot recognize JIT optimization success/failure. No rule observes monomorphic vs mega ICs, Maglev/Turbofan tier, or deopt.
| Rule | Sees | Does not see |
|---|---|---|
| jp-alloc-hot | .push({, non-empty binders, clear new containers (default scope all) | IC polymorphism; whether alloc causes deopt (usually it does not — it causes GC) |
| jp-console-hot | console.* (default scope all) | Whether logging is worth gating even off a hot path |
| jp-delete-shape | delete obj.key-style members | Dictionary mode already; deopt vs “just slow” |
| jp-delete-array-hole | delete arr[i] (holey) | Runtime elements kind; holey from skipped indices without delete |
| jp-string-plus-in-loop | removed | push + join was not about 2× on the matched += shapes |
| jp-map-filter-chain | chained .map / .filter / .flatMap on one line | Array length; a 3-row chain and a 35000-row chain are different costs |
Lint = allocation/shape smells. JIT verification = jit-check.sh / --trace-deopt / examples/bench. Polymorphic ICs leave no jp-* signal.
4.6 Measured on this machine (Node v22.22.3)
Ran 2026-10-21 18:49 Asia/Shanghai via scripts/run-v8-benches.sh → examples/bench/out/. Node v22.22.3, V8 12.4.254.21-node.56, linux x64. Warm-up 3 + 9 rounds; medians below. Single-machine numbers only — not a cross-machine claim.
| Bench | Variant | N | Median ms | Ops/s (approx.) |
|---|---|---|---|---|
| shape_mono_vs_poly | mono (same field order) | 2e5 | 0.219 | ~9.1e8 |
| shape_mono_vs_poly | poly (divergent order) | 2e5 | 1.435 | ~1.4e8 |
| elements_packed_vs_holey | packed fill+sum | 2e5 | 1.227 | ~1.6e8 |
| elements_packed_vs_holey | holey via delete | 2e5 | 4.589 | ~4.4e7 |
| elements_packed_vs_holey | holey via skipped index | 2e5 | 1.445 | ~1.4e8 |
| typed_array_vs_number_array | dense number[] fill+sum | 1e6 | 5.217 | ~1.9e8 |
| typed_array_vs_number_array | Float64Array fill+sum | 1e6 | 5.948 | ~1.7e8 |
Reading: poly shapes ~6.5× slower than mono here; delete-holey ~3.7× slower than packed. Skipped-index holey writes fewer slots — compare kind behavior, not raw speed alone. On this dense-double workload number[] was slightly faster than Float64Array; typed arrays still matter for fixed element types, avoiding packed→holey / Smi→double transitions, and binary/WASM boundaries — measure your loop.
Full JSONL: examples/bench/out/results.jsonl (written by the script).
4.7 Correlation: static smell vs V8 time (PARTIAL)
Experiment: examples/bench/static_vs_v8_correlate.js · scripts/run-static-vs-v8.sh (Node v22.22.3).
| Case | Static smell? | Ratio (smell/clean) | Agree? |
|---|---|---|---|
| Mono vs poly shapes | poly only | 12.0× | YES (+ wrong map deopt on mono→poly) |
Packed vs holey delete a[i] | jp-delete-array-hole-like | 5.47× | YES (time); deopt noisy |
Float64Array vs holey number[] | holey | 1.34× | PARTIAL (muted) |
delete obj.k vs = undefined | jp-delete-shape | 165× | YES (time); deopt counts alike |
Control: new in loop vs once | jp-alloc-hot | 49.3× | PARTIAL — alloc/GC pain, not deopt |
Do not pretend the jp- rules equal JIT awareness.* Agreement is strongest for shapes / holey / hot delete; weakest when using deopt line counts as an oracle or when selling typed arrays as always faster than packed doubles.
JSONL gates (Node v20.19.2 on this machine, re-run scripts/regen-evidence.sh): jp-delete-array-hole ~3.2× → error; jp-delete-shape ~474× (corpus hits almost all OK-idiom) → off; jp-alloc-hot ~19× (alloc/GC) → off; jp-console-hot ~1328× (discard sink, env-dependent) → off (app) / hint (engine). See evidence-policy and evidence/jp-*.md.
5. Startup and tooling (cold paths)
This chapter is about tooling, cold-start, and module-graph cost — not the hot-loop shape and allocation smells in §1–§2 / §4.
Primary source: Marvin Hagemeister’s series Speeding up the JavaScript ecosystem (parts 1–13). Methods below are paraphrased; do not treat his wall-clock numbers as ours — re-measure on your machine.
| Concern | Chapters | What finds it |
|---|---|---|
Runtime shape / alloc / holey / delete | §1, §2, §4 | the jp-* rules + examples/bench |
Module resolution, eager require, barrels, CLI overhead | §5 | node --cpu-prof / speedscope, require-graph inspection — not the jp-* rules |
5.1 Profile first (tooling)
Before rewriting a library or CLI:
- Capture a CPU profile:
node --cpu-prof your-tool.js …(ornode --cpu-prof $(which npm) run …for npm scripts — see part 4). - Open the
.cpuprofilein speedscope (or Chrome DevTools). - Ask: is time in your hot function, or in loading / resolving modules, casting, regex, FS?
Marvin’s recurring method (part 1): find a top frame → open source → remove avoidable work → re-profile. Same loop as XPerf’s “measure before rewrite,” aimed at build/lint/CLI rather than app render loops.
5.2 Hot casts, short-string regex, inner functions (part 1 methods)
Paraphrased patterns from part 1 (PostCSS, SVGO, and neighbors):
- String ↔ number churn — e.g.
toFixedthen+back to number in a tight loop. Prefer staying in number-land (Math.round/ scale by10**p). - Regex on many short strings — matching one regex against many tiny serialized fragments is often worse than one pass over a longer string, or a cheap pre-check that the needle exists at all.
- Functions created inside hot outer functions — closures recreated every call hurt ICs; lift helpers or pass args explicitly instead of capturing.
- Transpiler artifacts — e.g.
for…ofrewritten into a fresh_loopfunction per iteration; inspect published output, not only source.
These can overlap §4 (ICs) when the path is hot at runtime. In tooling they often dominate startup or per-file cost instead.
5.3 Module resolution (part 2 methods)
From part 2: resolution can outweigh parsing in lint/test/build traces.
His four takeaways, paraphrased:
- Avoid FS — every
stat/existsis expensive at scale. - Cache resolved paths (watch mode: invalidate carefully).
fs.statSync(path, { throwIfNoEntry: false })— avoid throwing + capturing stack traces for “file missing” (ENOENT path).- Limit upward
node_moduleswalks — especially when path aliases are mistaken for bare package imports.
Also watch extension guessing (.js → .jsx → … → .ts) and alias plugins that do not share the same resolver as TypeScript/tsconfig paths.
jsperf-lint cannot see this. No jp-* rule measures FS or resolution. Companions: CPU profiles, resolver caches, and the eager-vs-lazy sketch in examples/bench/require_cost_sketch.js.
5.4 Startup: eager graph, barrels, “gorilla holding a banana”
From part 4 (npm scripts) and part 7 (barrels):
- Eager
require/importloads the whole reachable graph up front. Error formatters, audit code, progress bars — anything unused on the happy path — should be lazy (requireinside theif (err)branch, dynamicimport()for rare commands). - Fat classes / mixin piles pull “the gorilla holding the banana and the entire jungle” (Joe Armstrong, via Marvin): one entry class that mixins audit + reify + rebuild when you only needed
run. - Barrel files (
index.jsthat onlyexport * from …) amplify the graph: one import can pull a folder tree; test runners that spawn per file pay the cost repeatedly. Prefer direct imports when startup or lint/test wall time matters. - Published bundle for CLIs can shrink load cost; measure — giant single files have a parse tradeoff.
5.5 Relation to the jp-* rules
| Runtime chapters (§1–§2, §4) | This chapter (§5) | |
|---|---|---|
| Typical win | Hot loop IC / GC / elements kind | Cold start, npm run, lint/test graph |
| Lint | the jp-* rules (line windows) | none today — document-only companions |
| Evidence | examples/bench/*, --trace-deopt | --cpu-prof + speedscope; require-graph size |
Do not interpret a clean jsperf-lint self-lint as “startup is fine.” Do not treat Marvin’s published speedups as transferable without a local profile.
5.6 Optional sketch: eager vs lazy require
examples/bench/require_cost_sketch.js builds a tiny fixture graph and times eager top-level require vs lazy require inside a rarely taken branch. It is a teaching microbench, not a claim about npm or your app. Run: node examples/bench/require_cost_sketch.js.
6. Constructors, JSON, DOM queries, and date formats
Each hint below is one measured shape. The hint line states that premise. A call that only looks similar is not given that rewrite. Default --scope all reports these smells everywhere — extreme stacks treat drip-by-drip cost as in-scope even off a loop. Opt in to --scope loop for the former in-loop-only match. Messages do not tell readers or models that non-loop sites can be ignored.
new RegExp(pattern) compiles on every call when the pattern stays the same. A regex literal is cached. If the pattern is the loop variable, or parts[i], the checker does not say to compile once. The bench compiled one fixed pattern, about 4.4× on Node 26.
JSON.parse of one unchanged text was about 140× on Node 26 versus parsing once. JSON.stringify of an unchanged value is the same shape. A JSONL line, or rows[i], is a different text every iteration. The checker skips an argument that is the loop binding. It does not tell you to fold those lines into one parse.
querySelector / getElementById of one unchanged selector was about 16× on Node 26, a tiny linkedom document. A selector built from the loop index is not that bench. new Intl.DateTimeFormat with unchanged locale and options was about 36× on Node 26; both sides still format. new Date is not this rule.
fs.readFileSync of one unchanged path was about 114× on Node 26 versus reading once. Under --scope loop, only the loop body after { is matched (a for iterable that runs the read once is then skipped). Under default --scope all, the call is reported wherever it appears when the path is stable. A path that changes every iteration (loop binding) is not reported.
Date.now was about 26× and performance.now about 19× versus one sample, in a tight loop that did not wait. A loop that contains await, or a for await loop, is not reported for the clock rule. Sample once when a fresh time is not required each call. A rate limiter that must read the clock each pass has nothing to move.
jp-regex-ctor-hot and jp-json-hot stay hint on the app and engine presets. JSONL exists for those two, and the 2026-10-22 verdict does not cover them. jp-dom-query-hot (about 16× on Node 26, a tiny linkedom document) and jp-intl-datetimeformat-hot (about 36× on Node 26, both sides still format) have JSONL and stay hint. They are outside that verdict. --preset all is error for every rule that still exists.
Reflect.get was about 8× and was not given a rule.
Lint: jp-regex-ctor-hot, jp-json-hot, jp-dom-query-hot, jp-intl-datetimeformat-hot, jp-readfile-sync-hot, jp-clock-hot (default hint).
Lint map
| ID | Topic | Severity | Evidence / note |
|---|---|---|---|
| jp-alloc-hot | object/array/new alloc | off (all: error) | jp-alloc-hot ~19× / ~6.4× — GC/alloc, not deopt |
| jp-console-hot | console.* | hint (engine) / off (app) | jp-console-hot ~1328× discard sink — env-dependent |
| jp-delete-shape | delete on plain members | off (all: error) | jp-delete-shape ~474× vs tombstone; corpus hits almost all OK-idiom |
| jp-delete-array-hole | delete with computed index (holey arrays) | error | jp-delete-array-hole ~3.2× vs packed |
| jp-map-filter-chain | chained .map / .filter / .flatMap | hint | long arrays about 4.6×–9.8×; a 3-row filter+map is 1.28× |
| jp-regex-ctor-hot | new RegExp, pattern not a loop binding | hint | Node 26 fixed pattern about 4.4×; prefer a literal or one shared compile |
| jp-json-hot | JSON.parse / stringify, text not a loop binding | hint | Node 26 one unchanged text about 140×; reuse when stable |
| jp-dom-query-hot | DOM query, selector not a loop binding | hint | JSONL about 16× on Node 26, a tiny linkedom document; not corpus-triaged |
| jp-intl-datetimeformat-hot | new Intl.DateTimeFormat | hint | Node 26 about 36×; both sides still format; not corpus-triaged |
| jp-readfile-sync-hot | readFileSync, path not a loop binding | hint | Node 26, one unchanged path about 114× |
| jp-clock-hot | Date.now / performance.now | hint | Node 26 about 70× / 110× with no await; sample once when fresh time is not required |
| (none) | module resolution / eager graph / barrels | — | §5 — use --cpu-prof |
Severities above are the default app preset. Matching scope defaults to --scope all (everywhere); --scope loop restores in-loop-only matching. Policy: evidence-policy.md. Fixtures: jsperf-lint/fixtures/{bad,good}/.
References
- corpus.md
examples/bench/·scripts/run-v8-benches.sh·scripts/jit-check.sh·scripts/run-static-vs-v8.sh·scripts/regen-evidence.sh·examples/bench/require_cost_sketch.js- XPerf playbook
- Marvin Hagemeister — Speeding up the JavaScript ecosystem (parts 1–13; methods cited in §5)