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.

Single-URL audit versus scripted flow A single-URL audit reports one page-level total, while a scripted flow isolates the interaction step and attributes main-thread time to it. Single-URL audit Scripted flow Cold load of one page interaction cost averaged in One page-level total regression hidden in the average navigate + load SearchInteraction own step + trace longestTask = 88ms attributed to the step

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.

Script command sequence and the measured window logData toggles bracket the setup navigation and the measured search step so only the interaction contributes to the recorded metrics. logData 0 metrics off navigate logData 1 metrics on setEventName label step execAndWait run + settle Measured window β€” only SearchInteraction contributes longestTask and totalBlocking computed here

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 frame-budget gate decision CI polls the WebPageTest API, reads the step's longestTask, and branches to a passing or failing exit code against the 16.6ms budget. Poll WPT API fetch result JSON Read step.longestTask SearchInteraction > 16.6ms? frame budget exit 1 block merge exit 0 gate passes yes no
// 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.

Main-thread timeline before and after batching The interleaved read-write loop is one 88ms long task, while the batched version splits reads and writes into sub-budget tasks. Before β€” interleaved reads and writes Task: filter loop β€” 88ms forced sync layout Γ—240 (LONG TASK) 16.6ms budget After β€” batched reads, then batched writes reads 9ms writes 7ms each task under the 16.6ms frame budget β†’ gate passes
// βœ… 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.