Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 18.5Advanced ~20 min · shot 5 of 6

Monitoring Motion in Production

In one line

Lab tests show that motion works on your machine; field data shows it works for your visitors. Measure Interaction to Next Paint and Long Animation Frames from real users with web-vitals, set a motion budget, put heavy effects behind a flag, and make a regression after a library update show up as a failed number.

0/6 · 0%

Think of it like this

A restaurant tests recipes in its own kitchen but learns the truth from the diners' tables: cold plates, long waits. The kitchen is the lab, the tables are the field, and only one tells you what customers feel.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

field data
Measurements from real visitors' browsers. Lab data comes from your own machine or a test.
INP
Interaction to Next Paint: the time from a tap, click or key press to the next painted frame, roughly the slowest of the visit.
LoAF
Long Animation Frame: an entry for any frame that took more than 50 ms, with the scripts that ran in it.
attribution
Extra data that says why a number is what it is: which element, which script, which part of the delay.

Step by step

01Report INP with its cause

Import the attribution build and send each result with sendBeacon. Keep the object small: the number, the target, the three parts and the longest script.

src/js/monitoring.jswhole filejs

`/analytics` is a placeholder for your own endpoint. `web-vitals` 6.2.3.

import { onINP } from "web-vitals/attribution";

onINP(({ name, value, rating, attribution: a }) => {
  navigator.sendBeacon("/analytics", JSON.stringify({
    name, value, rating,
    target: a.interactionTarget,
    inputDelay: a.inputDelay, processing: a.processingDuration, presentation: a.presentationDelay,
    script: a.longestScript?.entry.sourceFunctionName, // empty where Long Animation Frames are not supported
  }));
});

02See a real report

We made a page with a 180 ms click handler (addToCart) and one forced layout loop, loaded the attribution build in Chromium and clicked once. INP was 184 ms and rated good: a handler that feels slow can sit inside the threshold. The cause is in the same object.

terminal
$ node inp.cjs # Playwright, web-vitals 6.2.3, Chromium
── expected output ──
{
"name": "INP",
"value": 184,
"rating": "good",
"interactionTarget": "#buy",
"inputDelay": 0,
"processingDuration": 182,
"presentationDelay": 2,
"loadState": "complete",
"loafEntries": 1,
"totalScriptDuration": 180,
"totalStyleAndLayoutDuration": 1,
"longestScript": {
"invoker": "BUTTON#buy.onclick",
"invokerType": "event-listener",
"subpart": "processing-duration",
"sourceFunctionName": "addToCart"
}
}
Real output of one run; numbers differ between machines and runs. `reportAllChanges: true` was set only so the page reports without being closed.

03Write the budget

Put the limits in one object and check every release: bytes in CI, INP and long frames on a dashboard.

04Put the heavy effect behind a flag

Combine the flag, reduced motion, Data Saver and device hints in one function, and write down what an unknown hint means.

Try it yourself

  1. 1

    Change a phone

    In "A budget and a flag" set saveData: true for the launch laptop: off (data saver). Set cores: 2 instead: off (under 4 cores). The first failing rule wins.

  2. 2

    Make the update pass

    Change the "after" numbers to { inp: 190, longFrames: 4, scriptKB: 35 }. The line says pass: a value exactly on the limit (35) passes, because the check uses >.

Code & diagrams

Long tasks, and the limit of the sandbox

A 120 ms busy loop, like a heavy click handler. The `longtask` observer sees it. `long-animation-frame` is listed as supported but no entries arrive here, so the real attribution output is in the walkthrough.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

longtask listed: true, long-animation-frame listed: true
long tasks seen: 1, the longest over 50 ms: true, long animation frames seen: 0
A budget and a flag

One heavy effect decided for five visitors (a missing memory hint is unknown, not low), then two releases checked against a budget.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

launch laptop         on
Lena's cheap phone    off (under 4 GB memory)
Ravi                  off (reduced motion)
kill switch pulled    off (flag)
browser hides memory  on
before the library update   pass
after the library update    FAIL: inp 260 > 200, longFrames 9 > 5, scriptKB 45 > 35

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Treat a missing memory hint as zero

In "A budget and a flag" change if (memory !== undefined && memory < 4) to if ((memory ?? 0) < 4).

terminal
$ Run the example
── what you'll see ──
launch laptop on
Lena's cheap phone off (under 4 GB memory)
Ravi off (reduced motion)
kill switch pulled off (flag)
browser hides memory off (under 4 GB memory)

Myth vs fact

Myth

If INP is good in the lab, it is good for users.

Fact

Your lab has a fast machine and few tabs. Field data comes from many devices; the slow tail is where a motion problem lives.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Send one beacon per metric per page view, and sample at high volume.

  • ▸

    longAnimationFrameEntries is empty on browsers without the API, and frames under 50 ms never get an entry. In the web-vitals typings, totalUnattributedDuration holds scripts under 5 ms and time in frames under 50 ms, and it is undefined (not everything) when no long frame was seen. A large value means "slow, but not in a long frame we can name".

Remember this

  1. 1

    The problem this solves: your visitors use thousands of phones, busy tabs and slow networks that you never test. A library update that slows every tap shows up on nobody's laptop, and without measurement you hear about it from a complaint a week late.

  2. 2

    Interaction to Next Paint (INP) measures the time from a visitor's tap, click or key press until the browser paints the next frame, roughly the slowest of the visit. It has three parts: input delay (the main thread was busy), processing duration (your event handlers) and presentation delay (style, layout, paint, compositor). web-vitals rates it good at 200 ms or less and poor above 500 (INPThresholds in its source). Judge a percentile of many visits, not one: Google's guidance is the 75th.

  3. 3

    Long Animation Frames (LoAF). A frame over 50 ms gets an entry with the time spent in scripts, style and layout and, per script, who started it (invoker, such as BUTTON#buy.onclick) and the function name. That turns "INP was 260" into "addToCart ran for 180 ms". Where it is unsupported, web-vitals gives an empty longAnimationFrameEntries (check caniuse). The preview lists the type but delivers no entries (the first example prints 0), so the real output below comes from a normal page.

  4. 4

    The attribution build. web-vitals/attribution gives the number plus the cause: interactionTarget, the three parts, loadState, the LoAF entries, longestScript and script, style-and-layout and paint totals. It costs a little: onINP is 2,048 bytes gzipped in the plain build and 3,522 in the attribution one. Send results with navigator.sendBeacon, which survives the page closing.

  5. 5

    Budgets. A motion budget is a set of limits a release must stay under: INP, long frames per session, script bytes for the tools of Topic 18.3. It turns "feels slower" into "INP went from 180 to 260, over 200". The second example prints a pass for the old release and a named failure for the new one. Put the byte part in CI so a pull request that adds 14 KB fails before it merges.

  6. 6

    Flags for heavy effects. A heavy effect (a particle burst, a big blur) should run only when a flag is on and the visitor can afford it: not reduced motion, not Data Saver, enough memory and cores. navigator.deviceMemory and navigator.connection.saveData are Chromium-only and often missing, so decide what unknown means (the example runs the effect: missing is not low) and keep a kill switch, which turns an effect off for everyone without a deploy.

  7. 7

    A regression after a library update: the CI bundle check is a few KB over, a field chart shows INP creeping up on Android, attribution names one invoker, and you flip the flag off while you investigate. Without them you only have the complaint.

Explain it without notes

01

What is the difference between INP and a Long Animation Frame entry?

02

Which three pieces would have caught a library update that slowed every tap?

Practice

01

Make check() report no data instead of failing when a metric is undefined.

02

Why does the budget check say no data instead of counting a missing metric as 0?

Done when you can

  • You can say what INP and a Long Animation Frame are, and what each is good for

  • You can read an attribution report and name the slow script

  • You can write a motion budget and a check that names what failed

  • You can decide a heavy effect with a flag, reduced motion and device hints, and handle unknown hints