bye-thrash
v0.1.3
Published
Detect and destroy layout thrashing
Readme
JOYCO | Bye Thrash
Detect and destroy layout thrashing
A dev-only library that patches browser APIs to detect layout-triggering reads after style mutations in the same animation frame.
What is layout thrashing?
Layout thrashing occurs when JavaScript reads a layout property (like offsetWidth or getBoundingClientRect) immediately after writing to styles in the same frame. This forces the browser to perform a synchronous reflow, one of the most expensive operations in the rendering pipeline.
// Bad, triggers forced reflow
el.style.width = '100px'
const w = el.offsetWidth // browser must recalculate layout NOW
// Worse, N forced reflows in a loop
for (const box of boxes) {
box.style.width = box.offsetWidth + 10 + 'px'
}Installation
npm install bye-thrashQuick Start
'use client'
import { useEffect } from 'react'
import { thrash } from 'bye-thrash'
export function ThrashMonitor() {
useEffect(() => {
thrash.init()
return () => thrash.destroy()
}, [])
return null
}Drop <ThrashMonitor /> into your app layout. Open DevTools console, any layout thrashing will log warnings like:
[thrash] Layout read "offsetWidth" after style write. Call site: handleResize (src/components/Grid.tsx:42:8)API
thrash.init()
Patches browser APIs and begins detecting layout thrashing. Automatically skipped in production (NODE_ENV === 'production') unless explicitly enabled via configure({ enabled: true }).
thrash.destroy()
Restores all patched APIs to their originals, cancels the internal frame loop, and clears all recorded data. Safe to call multiple times.
thrash.configure(options)
Configure behavior before or after calling init().
thrash.configure({
mode: 'warn', // 'warn' | 'throw' | 'silent'
ignorePatterns: [], // (string | RegExp)[] — stack traces matching these are ignored
autoRaf: true, // whether bye-thrash manages its own requestAnimationFrame loop
scope: null, // Element | null — restrict detection to a DOM subtree
})| Option | Default | Description |
|--------|---------|-------------|
| mode | 'warn' | 'warn' logs to console, 'throw' throws an error, 'silent' records without output |
| ignorePatterns | [] | Stack trace patterns to ignore, useful for known third-party thrashing you can't fix |
| autoRaf | true | When true, bye-thrash runs its own rAF loop to reset frame state. Set to false if you manage frame timing yourself (see Manual tick) |
| scope | null | When set to a DOM element, only layout reads on elements inside that subtree are tracked. Reads outside the scope are completely ignored. When null, all reads are tracked |
thrash.tick()
Manually resets the frame state (dirtyFrame and per-frame read tracking). Only needed when autoRaf is false.
thrash.report(options?)
Returns an array of ReportEntry objects for all detected thrashing, sorted by count (highest first). Also logs a console.table (unless mode is 'silent').
const entries = thrash.report()
// [{ prop: 'offsetWidth', count: 12, callSite: '...', lastSeen: 1711600000000 }]
// Keep accumulated data (don't clear after reporting)
thrash.report({ clear: false })Analyze reports with Claude Code
If you use Claude Code, the thrash-report-analyzer skill can parse your report, trace each stack back to the offending source lines, and suggest fixes automatically.
Install the skill, then paste your report array and run /thrash-report-analyzer:
/thrash-report-analyzerScope
By default bye-thrash monitors the entire page. If you only care about thrashing inside a specific part of the DOM, set scope to a container element — any layout read outside that subtree will be silently ignored.
thrash.configure({
scope: document.getElementById('main-content'),
})
thrash.init()Under the hood, scope uses Element.contains() to check whether the element being read is a descendant of the scope boundary. The filtering happens early in the detection pipeline, so out-of-scope reads incur no tracking overhead.
Behavior details
| Scenario | Result |
|----------|--------|
| No scope set (null) | All layout reads are tracked |
| Scope set, read on element inside scope | Tracked normally |
| Scope set, read on element outside scope | Ignored |
| Scope set, read has no element (e.g. window.innerWidth) | Ignored |
React example
import { useRef, useEffect } from 'react'
import { thrash } from 'bye-thrash'
export function ScopedThrashMonitor({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
thrash.configure({ scope: ref.current })
thrash.init()
return () => thrash.destroy()
}, [])
return <div ref={ref}>{children}</div>
}Wrap the section you want to monitor — thrashing from third-party widgets, browser extensions, or unrelated parts of your app won't show up in the report.
What gets detected
Writes (mark frame as dirty)
| API | Example |
|-----|---------|
| Any el.style.* assignment | el.style.width = '100px' |
| style.setProperty() | el.style.setProperty('--x', '10') |
| setAttribute for style / class | el.setAttribute('class', 'foo') |
| classList.* | el.classList.add('active') |
| className | el.className = 'foo bar' |
Reads (trigger warning if frame is dirty)
| API | Example |
|-----|---------|
| offsetWidth/Height/Top/Left | el.offsetWidth |
| clientWidth/Height/Top/Left | el.clientHeight |
| scrollWidth/Height/Top/Left | el.scrollTop |
| getBoundingClientRect() | el.getBoundingClientRect() |
| getClientRects() | el.getClientRects() |
| getComputedStyle() | window.getComputedStyle(el) |
| innerText | el.innerText |
| window.innerWidth/Height | window.innerWidth |
| window.scrollX/Y | window.scrollY |
Manual tick
If you use a library like Tempus to orchestrate requestAnimationFrame callback order, bye-thrash's internal rAF loop may fire at the wrong time relative to your code, making the dirty/clean frame boundary unreliable.
Disable the internal loop and call tick() yourself at the start of each frame:
import { thrash } from 'bye-thrash'
import Tempus from 'tempus'
thrash.configure({ autoRaf: false })
thrash.init()
// Reset frame state before anything else runs in the frame.
// Use a very low priority so tick() executes first.
const unsub = Tempus.add(() => {
thrash.tick()
}, { priority: -100 })Why this matters
Tempus merges every requestAnimationFrame call in your app into a single orchestrated loop, executing callbacks in priority order (lowest first). When bye-thrash manages its own rAF (autoRaf: true), its tick() becomes just another unordered callback, it may run after your style writes have already happened, so the frame is never properly reset.
By calling tick() inside a Tempus callback with a very low priority (e.g. -100), you guarantee it runs before any of your application code, giving you an accurate clean/dirty frame boundary.
Priority guide
| Priority | Role |
|----------|------|
| -100 | thrash.tick(), reset frame state |
| 0 (default) | Your application code (animations, style writes, layout reads) |
| 100+ | Post-frame work (metrics, logging) |
Cleanup
The Tempus.add() call returns an unsubscribe function. Call it alongside thrash.destroy() when tearing down:
// React example
import { useEffect } from 'react'
import { thrash } from 'bye-thrash'
import Tempus from 'tempus'
export function ThrashMonitor() {
useEffect(() => {
thrash.configure({ autoRaf: false })
thrash.init()
const unsub = Tempus.add(() => {
thrash.tick()
}, { priority: -100 })
return () => {
unsub()
thrash.destroy()
}
}, [])
return null
}How to fix thrashing
Read first, write second:
const w = el.offsetWidth // read (clean frame)
el.style.width = w + 50 + 'px' // writeBatch reads and writes separately:
// All reads
const widths = boxes.map(b => b.offsetWidth)
// All writes
boxes.forEach((b, i) => {
b.style.width = widths[i] + 10 + 'px'
})Defer reads to the next frame:
el.style.width = '200px'
requestAnimationFrame(() => {
const rect = el.getBoundingClientRect() // safe, new frame
})Types
import type { ThrashConfig, ReportEntry, ReportOptions } from 'bye-thrash'Version Management
This library uses Changesets to manage versions and publish releases.
Adding a changeset
When you make changes that need to be released:
pnpm changesetThis will prompt you to:
- Select which packages you want to include in the changeset
- Choose whether it's a major/minor/patch bump
- Provide a summary of the changes
Creating a release
# 1. Create new versions of packages
pnpm version:package
# 2. Release (builds and publishes to npm)
pnpm release