[{"data":1,"prerenderedAt":1640},["ShallowReactive",2],{"content:\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing":3,"surroundings:\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing":1631},{"id":4,"title":5,"body":6,"description":1611,"extension":1612,"meta":1613,"navigation":1624,"path":1625,"seo":1626,"stem":1629,"__hash__":1630},"content\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002Findex.md","Tracking Route Transition Time with User Timing",{"type":7,"value":8,"toc":1596},"minimark",[9,14,29,37,182,187,225,229,235,249,255,261,265,270,273,660,663,667,822,825,957,961,964,1158,1161,1165,1176,1182,1186,1189,1379,1435,1439,1451,1455,1464,1474,1488,1505,1519,1525,1529,1545,1554,1558,1581,1586,1589,1592],[10,11,13],"h1",{"id":12},"how-to-measure-spa-route-transitions-with-performancemark-and-measure","How to Measure SPA Route Transitions with performance.mark() and measure()",[15,16,17,18,23,24,28],"p",{},"This guide extends ",[19,20,22],"a",{"href":21},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002F","Soft Navigations & SPA Metrics",", part of ",[19,25,27],{"href":26},"\u002Fcore-web-vitals-measurement\u002F","Core Web Vitals & Measurement",", with the single most useful custom metric a single-page app can have: route transition time, from the user's click to the new view's content being painted.",[15,30,31,32,36],{},"It is not a Core Web Vital, and that is its strength. Core Web Vitals are deliberately generic so they can be compared across the web; transition time is specific to your app's architecture, which makes it the number that tells you ",[33,34,35],"em",{},"why"," navigation feels slow. A product team that watches only INP sees the click handler; one that watches only route LCP sees the end result. Transition time, split into phases with User Timing, shows where the 1.4 seconds between them went.",[15,38,39],{},[40,41,47,48,47,55,47,59,47,62,47,80,47,88,47,94,47,102,47,109,47,114,47,117,47,121,47,124,47,130,47,134,47,137,47,140,47,144,47,147,47,150,47,154,47,157,47,161,47,170,47,174,47,178,47],"svg",{"viewBox":42,"width":43,"role":44,"ariaLabel":45,"style":46},"0 0 760 149","100%","img","The User Timing marks placed at each boundary of a soft navigation, from the click through chunk loading, data loading and render to the painted view.","height:auto;max-width:760px;display:block;margin:1.75rem auto;font-family:inherit;color:var(--fp-svg-ink)"," ",[49,50],"rect",{"className":51,"x":53,"y":53,"width":43,"height":43,"fill":54},[52],"svg-canvas","0","#ffffff",[56,57,58],"title",{},"Five marks that define a route transition",[60,61,45],"desc",{},[63,64,65],"defs",{},[66,67,74],"marker",{"id":68,"viewBox":69,"refX":70,"refY":71,"markerWidth":72,"markerHeight":72,"orient":73},"fa606b7962","0 0 10 10","9","5","7","auto-start-reverse",[75,76],"path",{"d":77,"fill":78,"style":79},"M0 0 L10 5 L0 10 z","currentColor","fill-opacity:0.7",[49,81],{"x":82,"y":82,"width":83,"height":84,"rx":85,"fill":86,"stroke":78,"style":87},"1","758","147","10","none","stroke-opacity:0.18",[89,90,58],"text",{"x":91,"y":92,"fill":78,"style":93},"28.0","34.0","font-size:16px;font-weight:700",[49,95],{"x":91,"y":96,"width":97,"height":98,"rx":99,"fill":100,"stroke":100,"style":101},"56.0","108.8","71.0","6","#0466c8","fill-opacity:0.14;stroke-opacity:0.9",[89,103,108],{"x":104,"y":105,"fill":78,"style":106,"textAnchor":107},"82.4","78.0","font-size:13px;font-weight:700","middle","nav:start",[89,110,113],{"x":104,"y":111,"fill":78,"style":112,"textAnchor":107},"96.0","font-size:12px","click timestamp",[49,115],{"x":116,"y":96,"width":97,"height":98,"rx":99,"fill":100,"stroke":100,"style":101},"176.8",[89,118,120],{"x":119,"y":105,"fill":78,"style":106,"textAnchor":107},"231.2","nav:code",[89,122,123],{"x":119,"y":111,"fill":78,"style":112,"textAnchor":107},"route chunk ready",[49,125],{"x":126,"y":96,"width":97,"height":98,"rx":99,"fill":127,"stroke":128,"style":129},"325.6","#ffc300","#b8860b","fill-opacity:0.24;stroke-opacity:0.9",[89,131,133],{"x":132,"y":105,"fill":78,"style":106,"textAnchor":107},"380.0","nav:data",[89,135,136],{"x":132,"y":111,"fill":78,"style":112,"textAnchor":107},"data resolved",[49,138],{"x":139,"y":96,"width":97,"height":98,"rx":99,"fill":100,"stroke":100,"style":101},"474.4",[89,141,143],{"x":142,"y":105,"fill":78,"style":106,"textAnchor":107},"528.8","nav:commit",[89,145,146],{"x":142,"y":111,"fill":78,"style":112,"textAnchor":107},"view rendered",[49,148],{"x":149,"y":96,"width":97,"height":98,"rx":99,"fill":100,"stroke":100,"style":101},"623.2",[89,151,153],{"x":152,"y":105,"fill":78,"style":106,"textAnchor":107},"677.6","nav:paint",[89,155,156],{"x":152,"y":111,"fill":78,"style":112,"textAnchor":107},"next frame",[89,158,160],{"x":152,"y":159,"fill":78,"style":112,"textAnchor":107},"112.0","painted",[162,163],"line",{"x1":164,"y1":165,"x2":166,"y2":165,"stroke":78,"strokeWidth":167,"style":168,"markerEnd":169},"136.8","91.5","174.8","1.5","stroke-opacity:0.6","url(#fa606b7962)",[162,171],{"x1":172,"y1":165,"x2":173,"y2":165,"stroke":78,"strokeWidth":167,"style":168,"markerEnd":169},"285.6","323.6",[162,175],{"x1":176,"y1":165,"x2":177,"y2":165,"stroke":78,"strokeWidth":167,"style":168,"markerEnd":169},"434.4","472.4",[162,179],{"x1":180,"y1":165,"x2":181,"y2":165,"stroke":78,"strokeWidth":167,"style":168,"markerEnd":169},"583.2","621.2",[183,184,186],"h2",{"id":185},"rapid-diagnosis","Rapid Diagnosis",[188,189,190,207,213,219],"ul",{},[191,192,193,197,198,202,203,206],"li",{},[194,195,196],"strong",{},"Time a transition by hand first."," Record a Performance trace while clicking between two routes; read the gap between the ",[199,200,201],"code",{},"click"," event and the first paint that shows the new content. If it exceeds ",[199,204,205],{},"1s"," on 4x CPU throttling, users notice.",[191,208,209,212],{},[194,210,211],{},"Check the Network track for serial requests."," A chunk request followed, after it completes, by an API request is the classic transition waterfall.",[191,214,215,218],{},[194,216,217],{},"Check for a loading state."," If the route shows a spinner for most of the transition, the data phase dominates.",[191,220,221,224],{},[194,222,223],{},"Look for a long task right before paint."," A large commit — rendering hundreds of components at once — shows as a single long task between data arriving and the paint.",[183,226,228],{"id":227},"root-cause-analysis","Root Cause Analysis",[15,230,231,234],{},[194,232,233],{},"1. Lazy route chunks fetched on click."," Code-split routes are good for initial load, but if the chunk is first requested when the user clicks, every transition pays a network round trip plus parse and evaluate.",[15,236,237,240,241,244,245,248],{},[194,238,239],{},"2. Data fetched after mount."," Components that fetch in ",[199,242,243],{},"useEffect"," or ",[199,246,247],{},"onMounted"," cannot start loading until the chunk is evaluated and the component has rendered once — a guaranteed serial waterfall.",[15,250,251,254],{},[194,252,253],{},"3. Over-rendering on commit."," The new route renders its entire tree, including below-the-fold content and heavy widgets, before the browser can paint anything.",[15,256,257,260],{},[194,258,259],{},"4. Blocking work in the old route's teardown."," Unmount logic — saving state, tearing down charts, cancelling subscriptions — runs synchronously before the new view renders.",[183,262,264],{"id":263},"step-by-step-resolution","Step-by-Step Resolution",[266,267,269],"h3",{"id":268},"_1-place-marks-at-each-phase-boundary","1. Place marks at each phase boundary",[15,271,272],{},"Use the router's lifecycle hooks and a tiny helper so every route is instrumented identically.",[274,275,280],"pre",{"className":276,"code":277,"language":278,"meta":279,"style":279},"language-javascript shiki shiki-themes github-light-high-contrast github-dark-high-contrast github-light-high-contrast","\u002F\u002F nav-timing.js\nlet navId = 0;\nexport function navMark(phase, detail = {}) {\n  performance.mark(`nav:${phase}`, { detail: { navId, ...detail } });\n}\nexport function beginNav(route, startTime) {\n  navId += 1;\n  performance.mark('nav:start', { startTime, detail: { navId, route } });\n}\nexport function endNav(route) {\n  requestAnimationFrame(() => setTimeout(() => {      \u002F\u002F after the next paint\n    navMark('paint');\n    const m = (from, to) => performance.measure(`nav:${from}->${to}`, `nav:${from}`, `nav:${to}`).duration;\n    report({ route, total: m('start', 'paint'), code: m('start', 'code'),\n             data: m('code', 'data'), render: m('data', 'paint') });\n  }, 0));\n}\n\u002F\u002F trade-off: performance.measure() between named marks uses the MOST RECENT\n\u002F\u002F mark of each name. Overlapping navigations corrupt the result; check navId\n\u002F\u002F in mark details or clear marks at each beginNav() in apps with rapid clicking.\n","javascript","",[199,281,282,290,311,343,372,378,401,415,430,435,451,476,490,555,593,626,637,642,648,654],{"__ignoreMap":279},[283,284,286],"span",{"class":162,"line":285},1,[283,287,289],{"class":288},"sjfSM","\u002F\u002F nav-timing.js\n",[283,291,293,297,301,304,308],{"class":162,"line":292},2,[283,294,296],{"class":295},"sPARh","let",[283,298,300],{"class":299},"saISM"," navId ",[283,302,303],{"class":295},"=",[283,305,307],{"class":306},"sPXB4"," 0",[283,309,310],{"class":299},";\n",[283,312,314,317,320,324,327,331,334,337,340],{"class":162,"line":313},3,[283,315,316],{"class":295},"export",[283,318,319],{"class":295}," function",[283,321,323],{"class":322},"smZ65"," navMark",[283,325,326],{"class":299},"(",[283,328,330],{"class":329},"sQw3B","phase",[283,332,333],{"class":299},", ",[283,335,336],{"class":329},"detail",[283,338,339],{"class":295}," =",[283,341,342],{"class":299}," {}) {\n",[283,344,346,349,352,354,358,360,363,366,369],{"class":162,"line":345},4,[283,347,348],{"class":299},"  performance.",[283,350,351],{"class":322},"mark",[283,353,326],{"class":299},[283,355,357],{"class":356},"sZ8jY","`nav:${",[283,359,330],{"class":299},[283,361,362],{"class":356},"}`",[283,364,365],{"class":299},", { detail: { navId, ",[283,367,368],{"class":295},"...",[283,370,371],{"class":299},"detail } });\n",[283,373,375],{"class":162,"line":374},5,[283,376,377],{"class":299},"}\n",[283,379,381,383,385,388,390,393,395,398],{"class":162,"line":380},6,[283,382,316],{"class":295},[283,384,319],{"class":295},[283,386,387],{"class":322}," beginNav",[283,389,326],{"class":299},[283,391,392],{"class":329},"route",[283,394,333],{"class":299},[283,396,397],{"class":329},"startTime",[283,399,400],{"class":299},") {\n",[283,402,404,407,410,413],{"class":162,"line":403},7,[283,405,406],{"class":299},"  navId ",[283,408,409],{"class":295},"+=",[283,411,412],{"class":306}," 1",[283,414,310],{"class":299},[283,416,418,420,422,424,427],{"class":162,"line":417},8,[283,419,348],{"class":299},[283,421,351],{"class":322},[283,423,326],{"class":299},[283,425,426],{"class":356},"'nav:start'",[283,428,429],{"class":299},", { startTime, detail: { navId, route } });\n",[283,431,433],{"class":162,"line":432},9,[283,434,377],{"class":299},[283,436,438,440,442,445,447,449],{"class":162,"line":437},10,[283,439,316],{"class":295},[283,441,319],{"class":295},[283,443,444],{"class":322}," endNav",[283,446,326],{"class":299},[283,448,392],{"class":329},[283,450,400],{"class":299},[283,452,454,457,460,463,466,468,470,473],{"class":162,"line":453},11,[283,455,456],{"class":322},"  requestAnimationFrame",[283,458,459],{"class":299},"(() ",[283,461,462],{"class":295},"=>",[283,464,465],{"class":322}," setTimeout",[283,467,459],{"class":299},[283,469,462],{"class":295},[283,471,472],{"class":299}," {      ",[283,474,475],{"class":288},"\u002F\u002F after the next paint\n",[283,477,479,482,484,487],{"class":162,"line":478},12,[283,480,481],{"class":322},"    navMark",[283,483,326],{"class":299},[283,485,486],{"class":356},"'paint'",[283,488,489],{"class":299},");\n",[283,491,493,496,499,501,504,507,509,512,515,517,520,523,525,527,529,532,534,536,538,540,542,544,546,548,550,552],{"class":162,"line":492},13,[283,494,495],{"class":295},"    const",[283,497,498],{"class":322}," m",[283,500,339],{"class":295},[283,502,503],{"class":299}," (",[283,505,506],{"class":329},"from",[283,508,333],{"class":299},[283,510,511],{"class":329},"to",[283,513,514],{"class":299},") ",[283,516,462],{"class":295},[283,518,519],{"class":299}," performance.",[283,521,522],{"class":322},"measure",[283,524,326],{"class":299},[283,526,357],{"class":356},[283,528,506],{"class":299},[283,530,531],{"class":356},"}->${",[283,533,511],{"class":299},[283,535,362],{"class":356},[283,537,333],{"class":299},[283,539,357],{"class":356},[283,541,506],{"class":299},[283,543,362],{"class":356},[283,545,333],{"class":299},[283,547,357],{"class":356},[283,549,511],{"class":299},[283,551,362],{"class":356},[283,553,554],{"class":299},").duration;\n",[283,556,558,561,564,567,569,572,574,576,579,581,583,585,587,590],{"class":162,"line":557},14,[283,559,560],{"class":322},"    report",[283,562,563],{"class":299},"({ route, total: ",[283,565,566],{"class":322},"m",[283,568,326],{"class":299},[283,570,571],{"class":356},"'start'",[283,573,333],{"class":299},[283,575,486],{"class":356},[283,577,578],{"class":299},"), code: ",[283,580,566],{"class":322},[283,582,326],{"class":299},[283,584,571],{"class":356},[283,586,333],{"class":299},[283,588,589],{"class":356},"'code'",[283,591,592],{"class":299},"),\n",[283,594,596,599,601,603,605,607,610,613,615,617,619,621,623],{"class":162,"line":595},15,[283,597,598],{"class":299},"             data: ",[283,600,566],{"class":322},[283,602,326],{"class":299},[283,604,589],{"class":356},[283,606,333],{"class":299},[283,608,609],{"class":356},"'data'",[283,611,612],{"class":299},"), render: ",[283,614,566],{"class":322},[283,616,326],{"class":299},[283,618,609],{"class":356},[283,620,333],{"class":299},[283,622,486],{"class":356},[283,624,625],{"class":299},") });\n",[283,627,629,632,634],{"class":162,"line":628},16,[283,630,631],{"class":299},"  }, ",[283,633,53],{"class":306},[283,635,636],{"class":299},"));\n",[283,638,640],{"class":162,"line":639},17,[283,641,377],{"class":299},[283,643,645],{"class":162,"line":644},18,[283,646,647],{"class":288},"\u002F\u002F trade-off: performance.measure() between named marks uses the MOST RECENT\n",[283,649,651],{"class":162,"line":650},19,[283,652,653],{"class":288},"\u002F\u002F mark of each name. Overlapping navigations corrupt the result; check navId\n",[283,655,657],{"class":162,"line":656},20,[283,658,659],{"class":288},"\u002F\u002F in mark details or clear marks at each beginNav() in apps with rapid clicking.\n",[15,661,662],{},"Expected outcome: every soft navigation yields a total and three phase durations.",[266,664,666],{"id":665},"_2-wire-the-marks-into-the-router","2. Wire the marks into the router",[274,668,670],{"className":276,"code":669,"language":278,"meta":279,"style":279},"\u002F\u002F Vue Router example — React Router and SvelteKit have equivalent hooks.\nrouter.beforeEach((to, from) => beginNav(to.path, lastClickTime ?? performance.now()));\nrouter.beforeResolve(() => navMark('code'));              \u002F\u002F async components resolved\nrouter.afterEach((to) => {\n  dataReady(to).then(() => { navMark('data'); endNav(to.matched.at(-1)?.path); });\n});\n\u002F\u002F trade-off: beforeResolve fires after async route components resolve, which is\n\u002F\u002F the right \"code\" boundary only if data loading is NOT also triggered there.\n\u002F\u002F If your data loaders run in beforeResolve, put the code mark in the chunk import.\n",[199,671,672,677,714,737,755,802,807,812,817],{"__ignoreMap":279},[283,673,674],{"class":162,"line":285},[283,675,676],{"class":288},"\u002F\u002F Vue Router example — React Router and SvelteKit have equivalent hooks.\n",[283,678,679,682,685,688,690,692,694,696,698,700,703,706,708,711],{"class":162,"line":292},[283,680,681],{"class":299},"router.",[283,683,684],{"class":322},"beforeEach",[283,686,687],{"class":299},"((",[283,689,511],{"class":329},[283,691,333],{"class":299},[283,693,506],{"class":329},[283,695,514],{"class":299},[283,697,462],{"class":295},[283,699,387],{"class":322},[283,701,702],{"class":299},"(to.path, lastClickTime ",[283,704,705],{"class":295},"??",[283,707,519],{"class":299},[283,709,710],{"class":322},"now",[283,712,713],{"class":299},"()));\n",[283,715,716,718,721,723,725,727,729,731,734],{"class":162,"line":313},[283,717,681],{"class":299},[283,719,720],{"class":322},"beforeResolve",[283,722,459],{"class":299},[283,724,462],{"class":295},[283,726,323],{"class":322},[283,728,326],{"class":299},[283,730,589],{"class":356},[283,732,733],{"class":299},"));              ",[283,735,736],{"class":288},"\u002F\u002F async components resolved\n",[283,738,739,741,744,746,748,750,752],{"class":162,"line":345},[283,740,681],{"class":299},[283,742,743],{"class":322},"afterEach",[283,745,687],{"class":299},[283,747,511],{"class":329},[283,749,514],{"class":299},[283,751,462],{"class":295},[283,753,754],{"class":299}," {\n",[283,756,757,760,763,766,768,770,773,776,778,780,783,786,789,792,794,797,799],{"class":162,"line":374},[283,758,759],{"class":322},"  dataReady",[283,761,762],{"class":299},"(to).",[283,764,765],{"class":322},"then",[283,767,459],{"class":299},[283,769,462],{"class":295},[283,771,772],{"class":299}," { ",[283,774,775],{"class":322},"navMark",[283,777,326],{"class":299},[283,779,609],{"class":356},[283,781,782],{"class":299},"); ",[283,784,785],{"class":322},"endNav",[283,787,788],{"class":299},"(to.matched.",[283,790,791],{"class":322},"at",[283,793,326],{"class":299},[283,795,796],{"class":295},"-",[283,798,82],{"class":306},[283,800,801],{"class":299},")?.path); });\n",[283,803,804],{"class":162,"line":380},[283,805,806],{"class":299},"});\n",[283,808,809],{"class":162,"line":403},[283,810,811],{"class":288},"\u002F\u002F trade-off: beforeResolve fires after async route components resolve, which is\n",[283,813,814],{"class":162,"line":417},[283,815,816],{"class":288},"\u002F\u002F the right \"code\" boundary only if data loading is NOT also triggered there.\n",[283,818,819],{"class":162,"line":432},[283,820,821],{"class":288},"\u002F\u002F If your data loaders run in beforeResolve, put the code mark in the chunk import.\n",[15,823,824],{},"Expected outcome: marks visible in the Performance panel's Timings track, aligned with network requests — the fastest way to see a waterfall.",[15,826,827],{},[40,828,47,831,47,834,47,837,47,839,47,842,47,844,47,852,47,856,47,862,47,868,47,872,47,876,47,881,47,885,47,889,47,892,47,895,47,898,47,902,47,905,47,908,47,913,47,919,47,923,47,927,47,931,47,935,47,938,47,942,47,946,47,952,47],{"viewBox":829,"width":43,"role":44,"ariaLabel":830,"style":46},"0 0 760 226","Two timelines comparing a serial chunk-then-data transition taking 1.3 seconds with a parallel version finishing in 0.7 seconds.",[49,832],{"className":833,"x":53,"y":53,"width":43,"height":43,"fill":54},[52],[56,835,836],{},"A slow transition before and after parallel loading",[60,838,830],{},[49,840],{"x":82,"y":82,"width":83,"height":841,"rx":85,"fill":86,"stroke":78,"style":87},"224",[89,843,836],{"x":91,"y":92,"fill":78,"style":93},[162,845],{"x1":846,"y1":847,"x2":846,"y2":848,"stroke":128,"strokeWidth":167,"strokeDashArray":849,"style":851},"543.5","72.0","156.0",[850,850],"4","stroke-opacity:0.9",[89,853,855],{"x":91,"y":111,"fill":78,"style":854},"font-size:12.5px;font-weight:700","Serial",[49,857],{"x":858,"y":105,"width":859,"height":860,"rx":861,"fill":100,"stroke":100,"style":101},"87.9","158.2","26.0","3",[89,863,867],{"x":864,"y":865,"fill":78,"style":866,"textAnchor":107},"167.0","95.5","font-size:11.5px","chunk",[49,869],{"x":870,"y":105,"width":871,"height":860,"rx":861,"fill":127,"stroke":128,"style":129},"247.6","317.9",[89,873,875],{"x":874,"y":865,"fill":78,"style":866,"textAnchor":107},"406.6","data after mount",[49,877],{"x":878,"y":105,"width":879,"height":860,"rx":861,"fill":78,"stroke":78,"style":880},"567.0","112.6","fill-opacity:0.06;stroke-opacity:0.4",[89,882,884],{"x":883,"y":865,"fill":78,"style":866,"textAnchor":107},"623.3","render",[89,886,888],{"x":91,"y":887,"fill":78,"style":854},"134.0","Parallel",[49,890],{"x":858,"y":891,"width":859,"height":860,"rx":861,"fill":100,"stroke":100,"style":101},"116.0",[89,893,867],{"x":864,"y":894,"fill":78,"style":866,"textAnchor":107},"133.5",[49,896],{"x":870,"y":891,"width":897,"height":860,"rx":861,"fill":127,"stroke":128,"style":129},"53.3",[89,899,901],{"x":900,"y":894,"fill":78,"style":866,"textAnchor":107},"274.3","data",[49,903],{"x":904,"y":891,"width":879,"height":860,"rx":861,"fill":78,"stroke":78,"style":880},"302.4",[89,906,884],{"x":907,"y":894,"fill":78,"style":866,"textAnchor":107},"358.7",[162,909],{"x1":910,"y1":848,"x2":911,"y2":848,"stroke":78,"style":912},"87.2","726.0","stroke-opacity:0.4",[89,914,918],{"x":915,"y":916,"fill":917,"style":866,"textAnchor":107},"97.9","173.0","#51617a","0ms",[89,920,922],{"x":921,"y":916,"fill":917,"style":866,"textAnchor":107},"178.4","200ms",[89,924,926],{"x":925,"y":916,"fill":917,"style":866,"textAnchor":107},"269.7","400ms",[89,928,930],{"x":929,"y":916,"fill":917,"style":866,"textAnchor":107},"361.0","600ms",[89,932,934],{"x":933,"y":916,"fill":917,"style":866,"textAnchor":107},"452.2","800ms",[89,936,937],{"x":846,"y":916,"fill":917,"style":866,"textAnchor":107},"1000ms",[89,939,941],{"x":940,"y":916,"fill":917,"style":866,"textAnchor":107},"634.7","1200ms",[89,943,945],{"x":944,"y":916,"fill":917,"style":866,"textAnchor":107},"712.3","1400ms",[89,947,951],{"x":846,"y":948,"fill":949,"style":950,"textAnchor":107},"68.0","#8a6608","font-size:12px;font-weight:700","1s budget",[89,953,956],{"x":91,"y":954,"fill":917,"style":955},"204.0","font-size:12.5px","In the parallel version the data request starts with the chunk request, so only its last 120ms shows after the chunk arrives.",[266,958,960],{"id":959},"_3-start-data-loading-at-navigation-start-not-on-mount","3. Start data loading at navigation start, not on mount",[15,962,963],{},"Move the data request out of the component and into a route-level loader that fires in parallel with the chunk.",[274,965,967],{"className":276,"code":966,"language":278,"meta":279,"style":279},"const loaders = { '\u002Fproduct\u002F:id': (p) => fetch(`\u002Fapi\u002Fproduct\u002F${p.id}`).then((r) => r.json()) };\nconst pending = new Map();\nrouter.beforeEach((to) => {\n  const loader = loaders[to.matched.at(-1)?.path];\n  if (loader) pending.set(to.fullPath, loader(to.params));   \u002F\u002F starts NOW\n});\nexport const useRouteData = (route) => pending.get(route.fullPath);\n\u002F\u002F trade-off: starting data on beforeEach means navigations the user abandons\n\u002F\u002F still hit the API. Pass an AbortController and abort on the next navigation.\n",[199,968,969,1034,1052,1068,1092,1115,1119,1148,1153],{"__ignoreMap":279},[283,970,971,974,977,979,981,984,987,989,991,993,996,998,1001,1003,1006,1009,1011,1014,1016,1018,1021,1023,1025,1028,1031],{"class":162,"line":285},[283,972,973],{"class":295},"const",[283,975,976],{"class":306}," loaders",[283,978,339],{"class":295},[283,980,772],{"class":299},[283,982,983],{"class":356},"'\u002Fproduct\u002F:id'",[283,985,986],{"class":299},": (",[283,988,15],{"class":329},[283,990,514],{"class":299},[283,992,462],{"class":295},[283,994,995],{"class":322}," fetch",[283,997,326],{"class":299},[283,999,1000],{"class":356},"`\u002Fapi\u002Fproduct\u002F${",[283,1002,15],{"class":299},[283,1004,1005],{"class":356},".",[283,1007,1008],{"class":299},"id",[283,1010,362],{"class":356},[283,1012,1013],{"class":299},").",[283,1015,765],{"class":322},[283,1017,687],{"class":299},[283,1019,1020],{"class":329},"r",[283,1022,514],{"class":299},[283,1024,462],{"class":295},[283,1026,1027],{"class":299}," r.",[283,1029,1030],{"class":322},"json",[283,1032,1033],{"class":299},"()) };\n",[283,1035,1036,1038,1041,1043,1046,1049],{"class":162,"line":292},[283,1037,973],{"class":295},[283,1039,1040],{"class":306}," pending",[283,1042,339],{"class":295},[283,1044,1045],{"class":295}," new",[283,1047,1048],{"class":322}," Map",[283,1050,1051],{"class":299},"();\n",[283,1053,1054,1056,1058,1060,1062,1064,1066],{"class":162,"line":313},[283,1055,681],{"class":299},[283,1057,684],{"class":322},[283,1059,687],{"class":299},[283,1061,511],{"class":329},[283,1063,514],{"class":299},[283,1065,462],{"class":295},[283,1067,754],{"class":299},[283,1069,1070,1073,1076,1078,1081,1083,1085,1087,1089],{"class":162,"line":345},[283,1071,1072],{"class":295},"  const",[283,1074,1075],{"class":306}," loader",[283,1077,339],{"class":295},[283,1079,1080],{"class":299}," loaders[to.matched.",[283,1082,791],{"class":322},[283,1084,326],{"class":299},[283,1086,796],{"class":295},[283,1088,82],{"class":306},[283,1090,1091],{"class":299},")?.path];\n",[283,1093,1094,1097,1100,1103,1106,1109,1112],{"class":162,"line":374},[283,1095,1096],{"class":295},"  if",[283,1098,1099],{"class":299}," (loader) pending.",[283,1101,1102],{"class":322},"set",[283,1104,1105],{"class":299},"(to.fullPath, ",[283,1107,1108],{"class":322},"loader",[283,1110,1111],{"class":299},"(to.params));   ",[283,1113,1114],{"class":288},"\u002F\u002F starts NOW\n",[283,1116,1117],{"class":162,"line":380},[283,1118,806],{"class":299},[283,1120,1121,1123,1126,1129,1131,1133,1135,1137,1139,1142,1145],{"class":162,"line":403},[283,1122,316],{"class":295},[283,1124,1125],{"class":295}," const",[283,1127,1128],{"class":322}," useRouteData",[283,1130,339],{"class":295},[283,1132,503],{"class":299},[283,1134,392],{"class":329},[283,1136,514],{"class":299},[283,1138,462],{"class":295},[283,1140,1141],{"class":299}," pending.",[283,1143,1144],{"class":322},"get",[283,1146,1147],{"class":299},"(route.fullPath);\n",[283,1149,1150],{"class":162,"line":417},[283,1151,1152],{"class":288},"\u002F\u002F trade-off: starting data on beforeEach means navigations the user abandons\n",[283,1154,1155],{"class":162,"line":432},[283,1156,1157],{"class":288},"\u002F\u002F still hit the API. Pass an AbortController and abort on the next navigation.\n",[15,1159,1160],{},"Expected outcome: the data phase shrinks by roughly the chunk download time — often 200–400ms on mobile.",[266,1162,1164],{"id":1163},"_4-render-the-first-screen-first","4. Render the first screen first",[15,1166,1167,1168,1171,1172,1175],{},"Defer below-the-fold sections of the route with ",[199,1169,1170],{},"content-visibility: auto",", lazy components, or a deliberate second render pass after paint, so the commit before the first paint stays under ",[199,1173,1174],{},"50ms"," of main-thread work.",[15,1177,1178,1179,1181],{},"Expected outcome: the render phase falls below ",[199,1180,922],{}," even on mid-tier devices, and INP for the navigation click improves as a side effect.",[183,1183,1185],{"id":1184},"budgeting-transitions-in-ci-and-rum","Budgeting Transitions in CI and RUM",[15,1187,1188],{},"Lab assertions catch architectural regressions (a chunk that stopped being prefetched); RUM budgets catch real-world drift.",[274,1190,1192],{"className":276,"code":1191,"language":278,"meta":279,"style":279},"\u002F\u002F playwright test — assert route transition stays under budget in the lab.\ntest('product route transition', async ({ page }) => {\n  await page.goto('\u002Fshoes');\n  await page.click('a[href=\"\u002Fshoes\u002F42\"]');\n  await page.waitForFunction(() => performance.getEntriesByName('nav:paint').length > 0);\n  const total = await page.evaluate(() =>\n    performance.getEntriesByName('nav:start->paint').at(-1)?.duration);\n  expect(total).toBeLessThan(600);\n});\n\u002F\u002F trade-off: CI runs on fast machines with warm caches, so a 600ms lab budget\n\u002F\u002F corresponds to a much slower field p75. Treat lab budgets as regression\n\u002F\u002F tripwires and set user-facing targets from RUM.\n",[199,1193,1194,1199,1227,1245,1260,1295,1317,1342,1360,1364,1369,1374],{"__ignoreMap":279},[283,1195,1196],{"class":162,"line":285},[283,1197,1198],{"class":288},"\u002F\u002F playwright test — assert route transition stays under budget in the lab.\n",[283,1200,1201,1204,1206,1209,1211,1214,1217,1220,1223,1225],{"class":162,"line":292},[283,1202,1203],{"class":322},"test",[283,1205,326],{"class":299},[283,1207,1208],{"class":356},"'product route transition'",[283,1210,333],{"class":299},[283,1212,1213],{"class":295},"async",[283,1215,1216],{"class":299}," ({ ",[283,1218,1219],{"class":329},"page",[283,1221,1222],{"class":299}," }) ",[283,1224,462],{"class":295},[283,1226,754],{"class":299},[283,1228,1229,1232,1235,1238,1240,1243],{"class":162,"line":313},[283,1230,1231],{"class":295},"  await",[283,1233,1234],{"class":299}," page.",[283,1236,1237],{"class":322},"goto",[283,1239,326],{"class":299},[283,1241,1242],{"class":356},"'\u002Fshoes'",[283,1244,489],{"class":299},[283,1246,1247,1249,1251,1253,1255,1258],{"class":162,"line":345},[283,1248,1231],{"class":295},[283,1250,1234],{"class":299},[283,1252,201],{"class":322},[283,1254,326],{"class":299},[283,1256,1257],{"class":356},"'a[href=\"\u002Fshoes\u002F42\"]'",[283,1259,489],{"class":299},[283,1261,1262,1264,1266,1269,1271,1273,1275,1278,1280,1283,1285,1288,1291,1293],{"class":162,"line":374},[283,1263,1231],{"class":295},[283,1265,1234],{"class":299},[283,1267,1268],{"class":322},"waitForFunction",[283,1270,459],{"class":299},[283,1272,462],{"class":295},[283,1274,519],{"class":299},[283,1276,1277],{"class":322},"getEntriesByName",[283,1279,326],{"class":299},[283,1281,1282],{"class":356},"'nav:paint'",[283,1284,1013],{"class":299},[283,1286,1287],{"class":306},"length",[283,1289,1290],{"class":295}," >",[283,1292,307],{"class":306},[283,1294,489],{"class":299},[283,1296,1297,1299,1302,1304,1307,1309,1312,1314],{"class":162,"line":380},[283,1298,1072],{"class":295},[283,1300,1301],{"class":306}," total",[283,1303,339],{"class":295},[283,1305,1306],{"class":295}," await",[283,1308,1234],{"class":299},[283,1310,1311],{"class":322},"evaluate",[283,1313,459],{"class":299},[283,1315,1316],{"class":295},"=>\n",[283,1318,1319,1322,1324,1326,1329,1331,1333,1335,1337,1339],{"class":162,"line":403},[283,1320,1321],{"class":299},"    performance.",[283,1323,1277],{"class":322},[283,1325,326],{"class":299},[283,1327,1328],{"class":356},"'nav:start->paint'",[283,1330,1013],{"class":299},[283,1332,791],{"class":322},[283,1334,326],{"class":299},[283,1336,796],{"class":295},[283,1338,82],{"class":306},[283,1340,1341],{"class":299},")?.duration);\n",[283,1343,1344,1347,1350,1353,1355,1358],{"class":162,"line":417},[283,1345,1346],{"class":322},"  expect",[283,1348,1349],{"class":299},"(total).",[283,1351,1352],{"class":322},"toBeLessThan",[283,1354,326],{"class":299},[283,1356,1357],{"class":306},"600",[283,1359,489],{"class":299},[283,1361,1362],{"class":162,"line":432},[283,1363,806],{"class":299},[283,1365,1366],{"class":162,"line":437},[283,1367,1368],{"class":288},"\u002F\u002F trade-off: CI runs on fast machines with warm caches, so a 600ms lab budget\n",[283,1370,1371],{"class":162,"line":453},[283,1372,1373],{"class":288},"\u002F\u002F corresponds to a much slower field p75. Treat lab budgets as regression\n",[283,1375,1376],{"class":162,"line":478},[283,1377,1378],{"class":288},"\u002F\u002F tripwires and set user-facing targets from RUM.\n",[15,1380,1381],{},[40,1382,47,1385,47,1388,47,1391,47,1393,47,1396,47,1398,47,1402,47,1407,47,1411,47,1414,47,1418,47,1420,47,1423,47,1427,47,1432,47],{"viewBox":1383,"width":43,"role":44,"ariaLabel":1384,"style":46},"0 0 760 162","A threshold scale for route transition time showing instant, acceptable and slow ranges with a current p75 marker.",[49,1386],{"className":1387,"x":53,"y":53,"width":43,"height":43,"fill":54},[52],[56,1389,1390],{},"Route transition time targets (field p75)",[60,1392,1384],{},[49,1394],{"x":82,"y":82,"width":83,"height":1395,"rx":85,"fill":86,"stroke":78,"style":87},"160",[89,1397,1390],{"x":91,"y":92,"fill":78,"style":93},[49,1399],{"x":91,"y":1400,"width":1401,"height":92,"rx":53,"fill":100,"stroke":100,"style":101},"82.0","105.6",[89,1403,1406],{"x":1404,"y":1405,"fill":78,"style":854,"textAnchor":107},"80.8","104.0","Instant",[89,1408,1410],{"x":1409,"y":887,"fill":78,"style":950,"textAnchor":107},"133.6","300ms",[49,1412],{"x":1409,"y":1400,"width":1413,"height":92,"rx":53,"fill":100,"stroke":100,"style":101},"246.4",[89,1415,1417],{"x":1416,"y":1405,"fill":78,"style":854,"textAnchor":107},"256.8","Acceptable",[89,1419,937],{"x":132,"y":887,"fill":78,"style":950,"textAnchor":107},[49,1421],{"x":132,"y":1400,"width":1422,"height":92,"rx":53,"fill":127,"stroke":128,"style":129},"352.0",[89,1424,1426],{"x":1425,"y":1405,"fill":78,"style":854,"textAnchor":107},"556.0","Feels like a page load",[162,1428],{"x1":1429,"y1":1430,"x2":1429,"y2":1431,"stroke":78,"strokeWidth":167,"style":851},"499.7","73.0","81.0",[89,1433,1434],{"x":1429,"y":948,"fill":100,"style":950,"textAnchor":107},"current p75: 1340ms",[183,1436,1438],{"id":1437},"verification","Verification",[15,1440,1441,1442,1444,1445,1447,1448,1450],{},"After each change, compare phase distributions rather than only the total. A successful prefetch shows up as a ",[199,1443,199],{}," phase near zero; a successful parallel loader shows the ",[199,1446,901],{}," phase shrinking by the chunk time; render work moved after paint shrinks ",[199,1449,884],{},". If the total improves but one phase grows, a fix has moved cost rather than removed it — common when prefetching makes the chunk arrive earlier but its evaluation then competes with the data parse.",[183,1452,1454],{"id":1453},"handling-the-awkward-navigations","Handling the Awkward Navigations",[15,1456,1457,1458,1461,1462,1005],{},"A transition metric is only trustworthy if it treats edge cases consistently. Decide these rules once and encode them in ",[199,1459,1460],{},"beginNav","\u002F",[199,1463,785],{},[15,1465,1466,1469,1470,1473],{},[194,1467,1468],{},"Abandoned navigations."," The user clicks route A, then route B before A paints. Record A with an ",[199,1471,1472],{},"aborted"," flag and its partial phases, and start B fresh. Excluding aborted navigations entirely hides your slowest experiences — users abandon precisely the slow ones.",[15,1475,1476,1479,1480,1483,1484,1487],{},[194,1477,1478],{},"Cached routes."," Back\u002Fforward navigations, and routes whose data is already in a client cache, often transition in under ",[199,1481,1482],{},"100ms",". They are real and good, but mixing them with cold transitions makes the p75 look better than the experience of a first visit to a route. Tag ",[199,1485,1486],{},"cache: 'warm' | 'cold'"," and report both.",[15,1489,1490,1493,1494,1497,1498,1501,1502,1504],{},[194,1491,1492],{},"Background tabs."," If the tab is hidden during a transition, ",[199,1495,1496],{},"requestAnimationFrame"," stops firing and your paint mark lands when the user returns — possibly minutes later. Check ",[199,1499,1500],{},"document.visibilityState"," at ",[199,1503,785],{}," and discard transitions that spanned a hidden period.",[15,1506,1507,1510,1511,1514,1515,1518],{},[194,1508,1509],{},"Redirects inside the router."," A guard that redirects ",[199,1512,1513],{},"\u002Faccount"," to ",[199,1516,1517],{},"\u002Flogin"," produces two navigations in quick succession. Attribute the total to the user's intent (the first route) and record the redirect as a phase; otherwise the redirect target looks fast and the original route looks like it never completed.",[15,1520,1521,1524],{},[194,1522,1523],{},"Transitions that open modals."," Some apps change the URL when a modal opens. Count these as transitions only if your product considers them views; otherwise filter them by route pattern so they do not dilute the p75 of real navigations.",[183,1526,1528],{"id":1527},"faq","FAQ",[1530,1531,1534,1538],"details",{"className":1532},[1533],"faq-item",[1535,1536,1537],"summary",{},"Should transition time be tracked per route template or per URL?",[15,1539,1540,1541,1544],{},"Per template. ",[199,1542,1543],{},"\u002Fproduct\u002F:id"," has one code path and one data shape regardless of which product, so its transition time is one distribution. Per-URL tracking adds cardinality without adding diagnostic value, except for a handful of outlier pages (a product with 400 variants) that are better found by sampling.",[1530,1546,1548,1551],{"className":1547},[1533],[1535,1549,1550],{},"How does this relate to route LCP?",[15,1552,1553],{},"They measure overlapping spans with different end points. Transition time ends at the first paint after your app considers the route ready; route LCP ends when the hero element paints, which may be later if the hero is an image still loading. Track both — transition time is what your code controls directly, route LCP is closer to what the user sees.",[183,1555,1557],{"id":1556},"related","Related",[188,1559,1560,1567,1574],{},[191,1561,1562,1566],{},[19,1563,1565],{"href":1564},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fmeasuring-lcp-after-client-side-route-changes\u002F","Measuring LCP after client-side route changes"," — the content-paint end point for the same navigations.",[191,1568,1569,1573],{},[19,1570,1572],{"href":1571},"\u002Fjavascript-bundle-optimization-code-splitting\u002Fdynamic-imports-and-route-based-splitting\u002Ffixing-waterfalls-from-nested-dynamic-imports\u002F","Fixing waterfalls from nested dynamic imports"," — when the code phase itself is a chain.",[191,1575,1576,1580],{},[19,1577,1579],{"href":1578},"\u002Fjavascript-bundle-optimization-code-splitting\u002Fdynamic-imports-and-route-based-splitting\u002Fimplementing-route-level-code-splitting-in-nextjs\u002F","Implementing route-level code splitting in Next.js"," — framework-specific splitting that sets the size of the code phase.",[1582,1583,1585],"script",{"type":1584},"application\u002Fld+json","\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"How to Measure SPA Route Transitions with performance.mark() and measure()\",\n  \"description\": \"Instrument click-to-content time for single-page app navigations with User Timing marks, broken into phases you can budget.\",\n  \"step\": [\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 1,\n      \"name\": \"Place marks at each phase boundary\",\n      \"text\": \"Use the router's lifecycle hooks and a tiny helper so every route is instrumented identically.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 2,\n      \"name\": \"Wire the marks into the router\",\n      \"text\": \"Expected outcome: marks visible in the Performance panel's Timings track, aligned with network requests — the fastest way to see a waterfall.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 3,\n      \"name\": \"Start data loading at navigation start, not on mount\",\n      \"text\": \"Move the data request out of the component and into a route-level loader that fires in parallel with the chunk.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 4,\n      \"name\": \"Render the first screen first\",\n      \"text\": \"Defer below-the-fold sections of the route with content-visibility: auto, lazy components, or a deliberate second render pass after paint, so the commit before the first paint stays under 50ms of main-thread work.\"\n    }\n  ]\n}\n",[1582,1587,1588],{"type":1584},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"TechArticle\",\n  \"headline\": \"How to Measure SPA Route Transitions with performance.mark() and measure()\",\n  \"description\": \"Instrument click-to-content time for single-page app navigations with User Timing marks, broken into phases you can budget.\",\n  \"datePublished\": \"2026-10-06\",\n  \"dateModified\": \"2026-10-06\",\n  \"author\": {\n    \"@type\": \"Organization\",\n    \"name\": \"frontend-performance.com\"\n  },\n  \"publisher\": {\n    \"@type\": \"Organization\",\n    \"name\": \"frontend-performance.com\"\n  },\n  \"mainEntityOfPage\": {\n    \"@type\": \"WebPage\",\n    \"@id\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002F\"\n  }\n}\n",[1582,1590,1591],{"type":1584},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"BreadcrumbList\",\n  \"itemListElement\": [\n    {\n      \"@type\": \"ListItem\",\n      \"position\": 1,\n      \"name\": \"Home\",\n      \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002F\"\n    },\n    {\n      \"@type\": \"ListItem\",\n      \"position\": 2,\n      \"name\": \"Core Web Vitals & Measurement\",\n      \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002F\"\n    },\n    {\n      \"@type\": \"ListItem\",\n      \"position\": 3,\n      \"name\": \"Soft Navigations & SPA Metrics\",\n      \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002F\"\n    },\n    {\n      \"@type\": \"ListItem\",\n      \"position\": 4,\n      \"name\": \"Tracking Route Transition Time with User Timing\",\n      \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002F\"\n    }\n  ]\n}\n",[1593,1594,1595],"style",{},"html pre.shiki code .sjfSM, html code.shiki .sjfSM{--shiki-default:#66707B;--shiki-dark:#BDC4CC;--shiki-light:#66707B}html pre.shiki code .sPARh, html code.shiki .sPARh{--shiki-default:#A0111F;--shiki-dark:#FF9492;--shiki-light:#A0111F}html pre.shiki code .saISM, html code.shiki .saISM{--shiki-default:#0E1116;--shiki-dark:#F0F3F6;--shiki-light:#0E1116}html pre.shiki code .sPXB4, html code.shiki .sPXB4{--shiki-default:#023B95;--shiki-dark:#91CBFF;--shiki-light:#023B95}html pre.shiki code .smZ65, html code.shiki .smZ65{--shiki-default:#622CBC;--shiki-dark:#DBB7FF;--shiki-light:#622CBC}html pre.shiki code .sQw3B, html code.shiki .sQw3B{--shiki-default:#702C00;--shiki-dark:#FFB757;--shiki-light:#702C00}html pre.shiki code .sZ8jY, html code.shiki .sZ8jY{--shiki-default:#032563;--shiki-dark:#ADDCFF;--shiki-light:#032563}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}",{"title":279,"searchDepth":292,"depth":292,"links":1597},[1598,1599,1600,1606,1607,1608,1609,1610],{"id":185,"depth":292,"text":186},{"id":227,"depth":292,"text":228},{"id":263,"depth":292,"text":264,"children":1601},[1602,1603,1604,1605],{"id":268,"depth":313,"text":269},{"id":665,"depth":313,"text":666},{"id":959,"depth":313,"text":960},{"id":1163,"depth":313,"text":1164},{"id":1184,"depth":292,"text":1185},{"id":1437,"depth":292,"text":1438},{"id":1453,"depth":292,"text":1454},{"id":1527,"depth":292,"text":1528},{"id":1556,"depth":292,"text":1557},"Instrument click-to-content time for single-page app navigations with User Timing marks, broken into phases you can budget.","md",{"slug":1614,"type":1615,"breadcrumb":1616,"datePublished":1623,"dateModified":1623},"tracking-route-transition-time-with-user-timing","article",[1617,1619,1620,1621],{"name":1618,"url":1461},"Home",{"name":27,"url":26},{"name":22,"url":21},{"name":5,"url":1622},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002F","2026-10-06",true,"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing",{"title":1627,"description":1628},"Tracking SPA Route Transition Time with User Timing","Measure click-to-content time for every SPA route change with performance.mark and measure, split it into chunk, data and render phases, and budget it.","core-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002Findex","tcgXvX8NBM9NC9ZZQYl652XBfIW29QXy0WhAdal86A0",[1632,1636],{"title":1633,"path":1634,"stem":1635,"children":-1},"Measuring LCP After Client-Side Route Changes","\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fmeasuring-lcp-after-client-side-route-changes","core-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fmeasuring-lcp-after-client-side-route-changes\u002Findex",{"title":1637,"path":1638,"stem":1639,"children":-1},"Why SPA CLS Accumulates Across Routes","\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes","core-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes\u002Findex",1791308074803]