Scripting WebPageTest for Frame Budget Regressions
Scripted WebPageTest runs drive multi-step flows, capture the full main-thread trace, and expose custom metrics you compute yourself β letting CI assert a specific interactionβs long-task cost against the 16.6ms frame budget rather than a single summary number. This builds on Lab Tooling and CI, part of Rendering Performance Metrics and Tooling.
Why Scripting, Not a Single URL
A one-URL audit measures cold page load. Real frame-budget regressions often hide behind interactions: a click that triggers a 90ms filter, a tab switch that forces a layout flush. WebPageTestβs scripting language navigates to that state, marks the step boundaries, and records a trace for each step, so you can attribute main-thread time to the exact interaction instead of averaging it into a page-level total.
The Scripting Language
WebPageTest scripts are line-oriented commands. The ones that matter for frame-budget work are navigate, exec (run JS in the page), setEventName (label a measurement step), and execAndWait (run JS and wait for activity to settle).
# wpt-search.txt β script a load, then a search interaction as its own step
logData 0
navigate https://example.com/
logData 1
setEventName SearchInteraction
execAndWait document.querySelector('#q').value='laptop'; \
document.querySelector('#q').dispatchEvent(new Event('input'))
logData 0 suppresses metrics during setup; logData 1 re-enables them so only the search step is measured. setEventName makes the step show up as a discrete entry in the result, with its own filmstrip and trace.
Custom Metrics from the Trace
WebPageTest lets you declare custom metrics as JavaScript that runs at the end and returns a number. To assert against the frame budget you need the longest task and total main-thread blocking for the interaction step, which you derive from the long-task entries the trace recorded.
[Custom Metrics]
longestTask
return performance.getEntriesByType('longtask')
.reduce((max, t) => Math.max(max, t.duration), 0); // ms of the worst block
totalBlocking
return performance.getEntriesByType('longtask')
.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0); // TBT-style sum
These surface as longestTask and totalBlocking in the JSON result alongside the filmstrip and the raw trace, so a CI step can read them and compare against budgets.
Asserting Against the 16.6ms Budget
The result JSON is fetched by the test API and checked in CI. The frame-budget assertion is simply: did any task in the interaction step exceed 16.6ms?
// CI gate: fail if the interaction step blocked a frame
const result = await fetchWPTResult(testId) // poll the WebPageTest API
const step = result.data.median.firstView.SearchInteraction
const FRAME_BUDGET = 16.6
if (step.longestTask > FRAME_BUDGET) {
console.error(`longest task ${step.longestTask}ms > ${FRAME_BUDGET}ms budget`)
process.exit(1) // non-zero blocks the merge
}
Reproduction: A Regression in the Interaction Step
// The search handler does a synchronous, layout-reading filter in one frame
input.addEventListener('input', () => {
for (const row of rows) {
row.style.display = matches(row, input.value) ? '' : 'none'
void row.offsetHeight // β forces a layout flush every iteration β long task
}
})
The per-row offsetHeight read interleaves a layout flush with every write, turning the loop into one long task. The scripted WebPageTest step captures it:
[WebPageTest trace β SearchInteraction step]
Main thread:
ββ Event: input ......................... 0.4ms
ββ Task (filter loop) .................. 88.0ms β£ LONG TASK
β ββ interleaved Layout Γ 240 rows (forced sync layout)
ββ Paint ................................ 5.0ms
Custom metrics: longestTask = 88 totalBlocking = 38
Frame budget 16.6ms exceeded β CI assertion FAILS (88 > 16.6)
The Fix
Separate the reads from the writes so layout flushes once, not per row, and the loop no longer blocks a frame past budget. This is the standard remedy for a forced synchronous layout β batch the measurements, then batch the mutations.
// β
One layout flush for all reads, then all writes β no per-row sync layout
input.addEventListener('input', () => {
const visible = rows.map((row) => matches(row, input.value)) // reads only (single flush)
rows.forEach((row, i) => {
row.style.display = visible[i] ? '' : 'none' // writes only β no interleaved reads
})
})
The re-run trace shows the loop split below the frame budget and the custom metrics back under threshold, so the gate passes. WebPageTestβs per-step attribution is what made the regression visible at the interaction level; correlated with field data, the same stall would appear as a long animation frame with the handler named in its scripts array.
Verification Checklist
| metric | target | how measured |
|---|---|---|
longestTask (interaction step) |
< 16.6ms | WebPageTest custom metric |
totalBlocking (interaction step) |
< 50ms | WebPageTest custom metric |
| Forced layout count in step | 0 | trace inspection of the step |
| CI exit code | 0 on fixed build | result-JSON assertion |
Scripting Interactions, Not Just Loads
The reason WebPageTest scripting is worth the setup over a plain Lighthouse run is that frame-budget regressions usually live in interactions, not the initial load, and a scripted test can drive those interactions deterministically. A script can navigate, wait for a specific element, click a control, scroll a defined distance, and capture the frame timing around each step, so the thing you measure is the animation or interaction that actually janks rather than a generic load metric. That determinism is what makes the number safe to gate on: the same script produces the same interaction on every run, so a regression in frame timing reflects a code change rather than test noise.
The output that matters for this purpose is the frame-by-frame filmstrip and the main-thread breakdown around each scripted step, which together show whether a specific interaction slipped past the budget and which phase β script, layout, or paint β caused it. Wiring that into CI as an assertion on the slowest interaction turns βsomeone will notice if scrolling gets jankyβ into a build that fails the moment a frame budget regresses, which is the whole point of moving the check left from production into the pipeline.
Frequently Asked Questions
Why script the interaction instead of just measuring Total Blocking Time on load?
Page-level Total Blocking Time folds the interaction cost into a single number that also includes parse, script evaluation, and hydration. A 88ms filter loop that fires on a keystroke barely moves the load-time aggregate, so the regression stays invisible. Scripting with setEventName gives the interaction its own step, its own trace, and its own longestTask, so CI can assert against the exact task you care about.
Does the longtask PerformanceObserver entry survive into the WebPageTest custom metric?
Yes β the custom metric JavaScript runs at the end of the step in the page context, so performance.getEntriesByType('longtask') returns the buffered entries the browser recorded during that step. Keep logData 0 around setup so earlier long tasks from navigation do not pollute the buffer you reduce over.
Why 16.6ms and not 50ms as the budget?
50ms is the long-task threshold β the point at which the main thread is considered blocked for input responsiveness. 16.6ms is the single-frame budget at 60fps: any task longer than that drops a frame during an animation or scroll. Frame-budget regression testing gates on 16.6ms because a janky interaction can be well under the 50ms long-task line and still stutter visibly.
How do I stop flaky WebPageTest runs from failing the gate?
Assert against the median of several runs rather than a single first view, and read result.data.median.firstView as the CI code does. Set the run count to at least three, pin the location and browser, and compare the median longestTask β a genuine regression moves the median, while a one-off scheduling hiccup does not.
Related Guides
- Lab Tooling and CI β how scripted WebPageTest fits alongside other lab gates in the pipeline.
- Forced Synchronous Layouts β the read/write interleaving that produced the long task this guide catches.
- Tracking Long Animation Frames β correlate the lab regression with the same stall in field data.