[{"data":1,"prerenderedAt":1288},["ShallowReactive",2],{"content:\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes":3,"surroundings:\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes":1279},{"id":4,"title":5,"body":6,"description":1258,"extension":1259,"meta":1260,"navigation":1272,"path":1273,"seo":1274,"stem":1277,"__hash__":1278},"content\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes\u002Findex.md","Why SPA CLS Accumulates Across Routes",{"type":7,"value":8,"toc":1244},"minimark",[9,14,34,42,193,198,251,255,261,267,277,283,383,387,392,649,652,656,659,909,912,916,928,1023,1030,1034,1041,1044,1124,1128,1134,1138,1141,1147,1157,1167,1177,1181,1193,1202,1206,1229,1234,1237,1240],[10,11,13],"h1",{"id":12},"why-your-spas-cls-gets-worse-the-longer-users-stay-and-how-to-attribute-it","Why Your SPA's CLS Gets Worse the Longer Users Stay — and How to Attribute It",[15,16,17,18,23,24,28,29,33],"p",{},"This guide is part of ",[19,20,22],"a",{"href":21},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002F","Soft Navigations & SPA Metrics"," in ",[19,25,27],{"href":26},"\u002Fcore-web-vitals-measurement\u002F","Core Web Vitals & Measurement",". The symptom: CLS for your landing pages is above ",[30,31,32],"code",{},"0.1"," at p75 in the field, but loading those pages in the lab produces almost no shift. Sessions with more page views have worse CLS. Bounced sessions look fine.",[15,35,36,37,41],{},"The cause is how Cumulative Layout Shift is defined. CLS is not the sum of all shifts; it is the largest ",[38,39,40],"em",{},"session window"," — a burst of shifts each less than one second apart, capped at five seconds. In a multi-page site, each document has a short life and few windows. In an SPA, one document lives for the entire session and every route contributes windows. The worst of them, from any route, becomes the session's CLS, filed against the landing URL.",[15,43,44],{},[45,46,52,53,52,60,52,64,52,67,52,76,52,82,52,87,52,96,52,103,52,108,52,112,52,119,52,123,52,127,52,132,52,137,52,140,52,144,52,148,52,152,52,159,52,165,52,169,52,173,52,177,52,181,52,184,52,188,52],"svg",{"viewBox":47,"width":48,"role":49,"ariaLabel":50,"style":51},"0 0 760 204","100%","img","Timeline of layout shifts across three SPA routes grouped into session windows, with the largest window occurring on the third route but charged to the landing page.","height:auto;max-width:760px;display:block;margin:1.75rem auto;font-family:inherit;color:var(--fp-svg-ink)"," ",[54,55],"rect",{"className":56,"x":58,"y":58,"width":48,"height":48,"fill":59},[57],"svg-canvas","0","#ffffff",[61,62,63],"title",{},"Session windows across three routes",[65,66,50],"desc",{},[54,68],{"x":69,"y":69,"width":70,"height":71,"rx":72,"fill":73,"stroke":74,"style":75},"1","758","202","10","none","currentColor","stroke-opacity:0.18",[77,78,63],"text",{"x":79,"y":80,"fill":74,"style":81},"28.0","34.0","font-size:16px;font-weight:700",[77,83,86],{"x":79,"y":84,"fill":74,"style":85},"74.0","font-size:12.5px;font-weight:700","Routes",[54,88],{"x":89,"y":90,"width":91,"height":92,"rx":93,"fill":94,"stroke":94,"style":95},"95.8","56.0","173.8","26.0","3","#0466c8","fill-opacity:0.14;stroke-opacity:0.9",[77,97,102],{"x":98,"y":99,"fill":74,"style":100,"textAnchor":101},"182.7","73.5","font-size:11.5px","middle","\u002Fhome",[54,104],{"x":105,"y":90,"width":106,"height":92,"rx":93,"fill":74,"stroke":74,"style":107},"271.1","243.9","fill-opacity:0.06;stroke-opacity:0.4",[77,109,111],{"x":110,"y":99,"fill":74,"style":100,"textAnchor":101},"393.0","\u002Fsearch",[54,113],{"x":114,"y":90,"width":115,"height":92,"rx":93,"fill":116,"stroke":117,"style":118},"516.4","208.8","#ffc300","#b8860b","fill-opacity:0.24;stroke-opacity:0.9",[77,120,122],{"x":121,"y":99,"fill":74,"style":100,"textAnchor":101},"620.8","\u002Fitem\u002F:id",[77,124,126],{"x":79,"y":125,"fill":74,"style":85},"112.0","Windows",[54,128],{"x":129,"y":130,"width":131,"height":92,"rx":93,"fill":94,"stroke":94,"style":95},"102.8","94.0","33.6",[77,133,136],{"x":134,"y":135,"fill":74,"style":100,"textAnchor":101},"119.6","111.5","0.02",[54,138],{"x":139,"y":130,"width":131,"height":92,"rx":93,"fill":74,"stroke":74,"style":107},"285.1",[77,141,143],{"x":142,"y":135,"fill":74,"style":100,"textAnchor":101},"301.9","0.04",[54,145],{"x":146,"y":130,"width":147,"height":92,"rx":93,"fill":116,"stroke":117,"style":118},"523.4","40.6",[77,149,151],{"x":150,"y":135,"fill":74,"style":100,"textAnchor":101},"543.7","0.19",[153,154],"line",{"x1":155,"y1":156,"x2":157,"y2":156,"stroke":74,"style":158},"95.1","134.0","726.0","stroke-opacity:0.4",[77,160,164],{"x":161,"y":162,"fill":163,"style":100,"textAnchor":101},"101.0","151.0","#51617a","0s",[77,166,168],{"x":167,"y":162,"fill":163,"style":100,"textAnchor":101},"200.2","15s",[77,170,172],{"x":171,"y":162,"fill":163,"style":100,"textAnchor":101},"305.4","30s",[77,174,176],{"x":175,"y":162,"fill":163,"style":100,"textAnchor":101},"410.5","45s",[77,178,180],{"x":179,"y":162,"fill":163,"style":100,"textAnchor":101},"515.7","60s",[77,182,183],{"x":121,"y":162,"fill":163,"style":100,"textAnchor":101},"75s",[77,185,187],{"x":186,"y":162,"fill":163,"style":100,"textAnchor":101},"723.0","90s",[77,189,192],{"x":79,"y":190,"fill":163,"style":191},"182.0","font-size:12.5px","CLS for this session is 0.19, the largest window, and it is reported against \u002Fhome.",[194,195,197],"h2",{"id":196},"rapid-diagnosis","Rapid Diagnosis",[199,200,201,209,227,245],"ul",{},[202,203,204,208],"li",{},[205,206,207],"strong",{},"Plot CLS against pages per session."," A rising curve means in-app routes are contributing shifts.",[202,210,211,214,215,218,219,222,223,226],{},[205,212,213],{},"Log shift timestamps with the active route."," In a test session, record each ",[30,216,217],{},"layout-shift"," entry's ",[30,220,221],{},"startTime",", ",[30,224,225],{},"value"," and the route at that time. The heavy window is usually on one route type.",[202,228,229,236,237,240,241,244],{},[205,230,231,232,235],{},"Inspect ",[30,233,234],{},"entry.sources","."," Each shift lists up to five affected nodes with ",[30,238,239],{},"previousRect"," and ",[30,242,243],{},"currentRect","; selectors from a route-specific component confirm where it came from.",[202,246,247,250],{},[205,248,249],{},"Watch for shifts right after navigation."," Many SPA shifts happen within the first second of a route render: content replacing a skeleton of the wrong height, or an image without dimensions.",[194,252,254],{"id":253},"root-cause-analysis","Root Cause Analysis",[15,256,257,260],{},[205,258,259],{},"1. One document, many windows."," The windowing rules are applied across the whole document lifetime. More routes means more opportunities for a bad window, so CLS grows with engagement even when no single route is unusually bad.",[15,262,263,266],{},[205,264,265],{},"2. Route renders that look like late content."," When a route's content arrives after data loads, it replaces a placeholder. If the placeholder height differs, the content below moves — and because the navigation click was more than 500ms earlier, the shift does not count as input-driven.",[15,268,269,272,273,276],{},[205,270,271],{},"3. The 500ms input exclusion is too short for SPAs."," Shifts within 500ms of a discrete input are excluded (",[30,274,275],{},"hadRecentInput","). A route transition that takes 800ms to load data falls outside this window, so its render shifts all count.",[15,278,279,282],{},[205,280,281],{},"4. Persistent shell elements reacting to route changes."," A header that changes height on certain routes, or a banner that appears on product pages, shifts everything below it on each navigation.",[15,284,285],{},[45,286,52,289,52,292,52,295,52,297,52,300,52,302,52,311,52,315,52,320,52,325,52,328,52,332,52,337,52,341,52,345,52,348,52,353,52,357,52,361,52,365,52,369,52,373,52,377,52],{"viewBox":287,"width":48,"role":49,"ariaLabel":288,"style":51},"0 0 760 200","Timeline showing the 500 millisecond input exclusion window after a click ending before route content arrives and shifts the layout.",[54,290],{"className":291,"x":58,"y":58,"width":48,"height":48,"fill":59},[57],[61,293,294],{},"Why route render shifts are not excused by the click",[65,296,288],{},[54,298],{"x":69,"y":69,"width":70,"height":299,"rx":72,"fill":73,"stroke":74,"style":75},"198",[77,301,294],{"x":79,"y":80,"fill":74,"style":81},[153,303],{"x1":304,"y1":305,"x2":304,"y2":306,"stroke":117,"strokeWidth":307,"strokeDashArray":308,"style":310},"347.7","72.0","156.0","1.5",[309,309],"4","stroke-opacity:0.9",[77,312,314],{"x":79,"y":313,"fill":74,"style":85},"96.0","Input",[54,316],{"x":317,"y":318,"width":319,"height":92,"rx":93,"fill":74,"stroke":74,"style":107},"78.2","78.0","268.7",[77,321,324],{"x":322,"y":323,"fill":74,"style":100,"textAnchor":101},"212.6","95.5","shifts excluded",[77,326,327],{"x":79,"y":156,"fill":74,"style":85},"Route",[54,329],{"x":317,"y":330,"width":331,"height":92,"rx":93,"fill":94,"stroke":94,"style":95},"116.0","441.7",[77,333,336],{"x":334,"y":335,"fill":74,"style":100,"textAnchor":101},"299.1","133.5","chunk + data",[54,338],{"x":339,"y":330,"width":340,"height":92,"rx":93,"fill":116,"stroke":117,"style":118},"521.4","41.7",[77,342,344],{"x":343,"y":335,"fill":74,"style":100,"textAnchor":101},"542.2","shift",[153,346],{"x1":347,"y1":306,"x2":157,"y2":306,"stroke":74,"style":158},"77.5",[77,349,352],{"x":350,"y":351,"fill":163,"style":100,"textAnchor":101},"88.2","173.0","0ms",[77,354,356],{"x":355,"y":351,"fill":163,"style":100,"textAnchor":101},"185.6","200ms",[77,358,360],{"x":359,"y":351,"fill":163,"style":100,"textAnchor":101},"293.6","400ms",[77,362,364],{"x":363,"y":351,"fill":163,"style":100,"textAnchor":101},"401.7","600ms",[77,366,368],{"x":367,"y":351,"fill":163,"style":100,"textAnchor":101},"509.8","800ms",[77,370,372],{"x":371,"y":351,"fill":163,"style":100,"textAnchor":101},"617.9","1000ms",[77,374,376],{"x":375,"y":351,"fill":163,"style":100,"textAnchor":101},"712.3","1200ms",[77,378,382],{"x":304,"y":379,"fill":380,"style":381,"textAnchor":101},"68.0","#8a6608","font-size:12px;font-weight:700","500ms exclusion ends",[194,384,386],{"id":385},"step-by-step-resolution","Step-by-Step Resolution",[388,389,391],"h3",{"id":390},"_1-attribute-every-shift-to-the-route-that-was-active","1. Attribute every shift to the route that was active",[393,394,399],"pre",{"className":395,"code":396,"language":397,"meta":398,"style":398},"language-javascript shiki shiki-themes github-light-high-contrast github-dark-high-contrast github-light-high-contrast","import { routeAt } from '.\u002Froute-history.js';\nconst shiftsByRoute = new Map();\nnew PerformanceObserver((list) => {\n  for (const e of list.getEntries()) {\n    if (e.hadRecentInput) continue;\n    const route = routeAt(e.startTime);\n    const arr = shiftsByRoute.get(route) || [];\n    arr.push({ t: e.startTime, v: e.value,\n      node: e.sources?.[0]?.node?.closest('[data-component]')?.dataset.component });\n    shiftsByRoute.set(route, arr);\n  }\n}).observe({ type: 'layout-shift', buffered: true });\n\u002F\u002F trade-off: keeping every shift in memory is fine for typical sessions but\n\u002F\u002F grows without bound in apps left open for hours. Fold shifts into window\n\u002F\u002F maxima as they arrive (step 2) if sessions are long.\n","javascript","",[30,400,401,423,446,471,497,511,528,553,565,588,600,606,630,637,643],{"__ignoreMap":398},[402,403,405,409,413,416,420],"span",{"class":153,"line":404},1,[402,406,408],{"class":407},"sPARh","import",[402,410,412],{"class":411},"saISM"," { routeAt } ",[402,414,415],{"class":407},"from",[402,417,419],{"class":418},"sZ8jY"," '.\u002Froute-history.js'",[402,421,422],{"class":411},";\n",[402,424,426,429,433,436,439,443],{"class":153,"line":425},2,[402,427,428],{"class":407},"const",[402,430,432],{"class":431},"sPXB4"," shiftsByRoute",[402,434,435],{"class":407}," =",[402,437,438],{"class":407}," new",[402,440,442],{"class":441},"smZ65"," Map",[402,444,445],{"class":411},"();\n",[402,447,449,452,455,458,462,465,468],{"class":153,"line":448},3,[402,450,451],{"class":407},"new",[402,453,454],{"class":441}," PerformanceObserver",[402,456,457],{"class":411},"((",[402,459,461],{"class":460},"sQw3B","list",[402,463,464],{"class":411},") ",[402,466,467],{"class":407},"=>",[402,469,470],{"class":411}," {\n",[402,472,474,477,480,482,485,488,491,494],{"class":153,"line":473},4,[402,475,476],{"class":407},"  for",[402,478,479],{"class":411}," (",[402,481,428],{"class":407},[402,483,484],{"class":431}," e",[402,486,487],{"class":407}," of",[402,489,490],{"class":411}," list.",[402,492,493],{"class":441},"getEntries",[402,495,496],{"class":411},"()) {\n",[402,498,500,503,506,509],{"class":153,"line":499},5,[402,501,502],{"class":407},"    if",[402,504,505],{"class":411}," (e.hadRecentInput) ",[402,507,508],{"class":407},"continue",[402,510,422],{"class":411},[402,512,514,517,520,522,525],{"class":153,"line":513},6,[402,515,516],{"class":407},"    const",[402,518,519],{"class":431}," route",[402,521,435],{"class":407},[402,523,524],{"class":441}," routeAt",[402,526,527],{"class":411},"(e.startTime);\n",[402,529,531,533,536,538,541,544,547,550],{"class":153,"line":530},7,[402,532,516],{"class":407},[402,534,535],{"class":431}," arr",[402,537,435],{"class":407},[402,539,540],{"class":411}," shiftsByRoute.",[402,542,543],{"class":441},"get",[402,545,546],{"class":411},"(route) ",[402,548,549],{"class":407},"||",[402,551,552],{"class":411}," [];\n",[402,554,556,559,562],{"class":153,"line":555},8,[402,557,558],{"class":411},"    arr.",[402,560,561],{"class":441},"push",[402,563,564],{"class":411},"({ t: e.startTime, v: e.value,\n",[402,566,568,571,573,576,579,582,585],{"class":153,"line":567},9,[402,569,570],{"class":411},"      node: e.sources?.[",[402,572,58],{"class":431},[402,574,575],{"class":411},"]?.node?.",[402,577,578],{"class":441},"closest",[402,580,581],{"class":411},"(",[402,583,584],{"class":418},"'[data-component]'",[402,586,587],{"class":411},")?.dataset.component });\n",[402,589,591,594,597],{"class":153,"line":590},10,[402,592,593],{"class":411},"    shiftsByRoute.",[402,595,596],{"class":441},"set",[402,598,599],{"class":411},"(route, arr);\n",[402,601,603],{"class":153,"line":602},11,[402,604,605],{"class":411},"  }\n",[402,607,609,612,615,618,621,624,627],{"class":153,"line":608},12,[402,610,611],{"class":411},"}).",[402,613,614],{"class":441},"observe",[402,616,617],{"class":411},"({ type: ",[402,619,620],{"class":418},"'layout-shift'",[402,622,623],{"class":411},", buffered: ",[402,625,626],{"class":431},"true",[402,628,629],{"class":411}," });\n",[402,631,633],{"class":153,"line":632},13,[402,634,636],{"class":635},"sjfSM","\u002F\u002F trade-off: keeping every shift in memory is fine for typical sessions but\n",[402,638,640],{"class":153,"line":639},14,[402,641,642],{"class":635},"\u002F\u002F grows without bound in apps left open for hours. Fold shifts into window\n",[402,644,646],{"class":153,"line":645},15,[402,647,648],{"class":635},"\u002F\u002F maxima as they arrive (step 2) if sessions are long.\n",[15,650,651],{},"Expected outcome: a per-route list of shifts with a responsible component name.",[388,653,655],{"id":654},"_2-compute-the-cls-window-maximum-per-route","2. Compute the CLS window maximum per route",[15,657,658],{},"Apply the official windowing rules within each route's shifts, starting fresh at each navigation.",[393,660,662],{"className":395,"code":661,"language":397,"meta":398,"style":398},"function windowedCLS(shifts) {\n  let max = 0, cur = 0, first = 0, prev = 0;\n  for (const s of shifts) {\n    if (cur && (s.t - prev > 1000 || s.t - first > 5000)) { max = Math.max(max, cur); cur = 0; }\n    if (!cur) first = s.t;\n    cur += s.v; prev = s.t;\n  }\n  return Math.max(max, cur);\n}\nconst perRouteCLS = [...shiftsByRoute].map(([route, s]) => ({ route, cls: windowedCLS(s) }));\n\u002F\u002F trade-off: splitting windows at route boundaries means a burst that straddles\n\u002F\u002F a navigation is counted as two smaller windows. That understates CLS slightly\n\u002F\u002F compared with the official document-level value; document it on dashboards.\n",[30,663,664,680,717,733,795,812,827,831,843,848,894,899,904],{"__ignoreMap":398},[402,665,666,669,672,674,677],{"class":153,"line":404},[402,667,668],{"class":407},"function",[402,670,671],{"class":441}," windowedCLS",[402,673,581],{"class":411},[402,675,676],{"class":460},"shifts",[402,678,679],{"class":411},") {\n",[402,681,682,685,688,691,694,697,699,701,704,706,708,711,713,715],{"class":153,"line":425},[402,683,684],{"class":407},"  let",[402,686,687],{"class":411}," max ",[402,689,690],{"class":407},"=",[402,692,693],{"class":431}," 0",[402,695,696],{"class":411},", cur ",[402,698,690],{"class":407},[402,700,693],{"class":431},[402,702,703],{"class":411},", first ",[402,705,690],{"class":407},[402,707,693],{"class":431},[402,709,710],{"class":411},", prev ",[402,712,690],{"class":407},[402,714,693],{"class":431},[402,716,422],{"class":411},[402,718,719,721,723,725,728,730],{"class":153,"line":448},[402,720,476],{"class":407},[402,722,479],{"class":411},[402,724,428],{"class":407},[402,726,727],{"class":431}," s",[402,729,487],{"class":407},[402,731,732],{"class":411}," shifts) {\n",[402,734,735,737,740,743,746,749,752,755,758,761,764,766,769,771,774,777,779,782,785,788,790,792],{"class":153,"line":473},[402,736,502],{"class":407},[402,738,739],{"class":411}," (cur ",[402,741,742],{"class":407},"&&",[402,744,745],{"class":411}," (s.t ",[402,747,748],{"class":407},"-",[402,750,751],{"class":411}," prev ",[402,753,754],{"class":407},">",[402,756,757],{"class":431}," 1000",[402,759,760],{"class":407}," ||",[402,762,763],{"class":411}," s.t ",[402,765,748],{"class":407},[402,767,768],{"class":411}," first ",[402,770,754],{"class":407},[402,772,773],{"class":431}," 5000",[402,775,776],{"class":411},")) { max ",[402,778,690],{"class":407},[402,780,781],{"class":411}," Math.",[402,783,784],{"class":441},"max",[402,786,787],{"class":411},"(max, cur); cur ",[402,789,690],{"class":407},[402,791,693],{"class":431},[402,793,794],{"class":411},"; }\n",[402,796,797,799,801,804,807,809],{"class":153,"line":499},[402,798,502],{"class":407},[402,800,479],{"class":411},[402,802,803],{"class":407},"!",[402,805,806],{"class":411},"cur) first ",[402,808,690],{"class":407},[402,810,811],{"class":411}," s.t;\n",[402,813,814,817,820,823,825],{"class":153,"line":513},[402,815,816],{"class":411},"    cur ",[402,818,819],{"class":407},"+=",[402,821,822],{"class":411}," s.v; prev ",[402,824,690],{"class":407},[402,826,811],{"class":411},[402,828,829],{"class":153,"line":530},[402,830,605],{"class":411},[402,832,833,836,838,840],{"class":153,"line":555},[402,834,835],{"class":407},"  return",[402,837,781],{"class":411},[402,839,784],{"class":441},[402,841,842],{"class":411},"(max, cur);\n",[402,844,845],{"class":153,"line":567},[402,846,847],{"class":411},"}\n",[402,849,850,852,855,857,860,863,866,869,872,875,877,880,883,885,888,891],{"class":153,"line":590},[402,851,428],{"class":407},[402,853,854],{"class":431}," perRouteCLS",[402,856,435],{"class":407},[402,858,859],{"class":411}," [",[402,861,862],{"class":407},"...",[402,864,865],{"class":411},"shiftsByRoute].",[402,867,868],{"class":441},"map",[402,870,871],{"class":411},"(([",[402,873,874],{"class":460},"route",[402,876,222],{"class":411},[402,878,879],{"class":460},"s",[402,881,882],{"class":411},"]) ",[402,884,467],{"class":407},[402,886,887],{"class":411}," ({ route, cls: ",[402,889,890],{"class":441},"windowedCLS",[402,892,893],{"class":411},"(s) }));\n",[402,895,896],{"class":153,"line":602},[402,897,898],{"class":635},"\u002F\u002F trade-off: splitting windows at route boundaries means a burst that straddles\n",[402,900,901],{"class":153,"line":608},[402,902,903],{"class":635},"\u002F\u002F a navigation is counted as two smaller windows. That understates CLS slightly\n",[402,905,906],{"class":153,"line":632},[402,907,908],{"class":635},"\u002F\u002F compared with the official document-level value; document it on dashboards.\n",[15,910,911],{},"Expected outcome: per-route CLS values that use the same rules as the official metric and add up to an explanation of the session value.",[388,913,915],{"id":914},"_3-reserve-space-for-route-content-before-data-arrives","3. Reserve space for route content before data arrives",[15,917,918,919,922,923,927],{},"Most SPA route shifts come from placeholders with the wrong size. Give skeletons the real content's dimensions — fixed heights for cards, ",[30,920,921],{},"aspect-ratio"," for media — or render the route's layout frame from known metadata before data arrives. ",[19,924,926],{"href":925},"\u002Fcore-web-vitals-measurement\u002Freducing-cumulative-layout-shift-cls\u002Ffixing-cls-from-skeleton-screens-that-resize\u002F","Fixing CLS from skeleton screens that resize"," covers the techniques in detail.",[393,929,933],{"className":930,"code":931,"language":932,"meta":398,"style":398},"language-css shiki shiki-themes github-light-high-contrast github-dark-high-contrast github-light-high-contrast",".product-hero { aspect-ratio: 1 \u002F 1; }                 \u002F* image area never collapses *\u002F\n.product-meta { min-height: 7.5rem; }                   \u002F* title + price + rating *\u002F\n.related-rail { min-height: 22rem; contain: layout; }   \u002F* async rail cannot push the footer *\u002F\n\u002F* trade-off: min-height reserves space even when content is shorter, leaving\n   gaps on sparse products. Measure the p75 content height, not the maximum. *\u002F\n","css",[30,934,935,961,985,1013,1018],{"__ignoreMap":398},[402,936,937,940,943,945,948,950,953,955,958],{"class":153,"line":404},[402,938,939],{"class":431},".product-hero",[402,941,942],{"class":411}," { ",[402,944,921],{"class":431},[402,946,947],{"class":411},": ",[402,949,69],{"class":431},[402,951,952],{"class":411}," \u002F ",[402,954,69],{"class":431},[402,956,957],{"class":411},"; }                 ",[402,959,960],{"class":635},"\u002F* image area never collapses *\u002F\n",[402,962,963,966,968,971,973,976,979,982],{"class":153,"line":425},[402,964,965],{"class":431},".product-meta",[402,967,942],{"class":411},[402,969,970],{"class":431},"min-height",[402,972,947],{"class":411},[402,974,975],{"class":431},"7.5",[402,977,978],{"class":407},"rem",[402,980,981],{"class":411},"; }                   ",[402,983,984],{"class":635},"\u002F* title + price + rating *\u002F\n",[402,986,987,990,992,994,996,999,1001,1004,1007,1010],{"class":153,"line":448},[402,988,989],{"class":431},".related-rail",[402,991,942],{"class":411},[402,993,970],{"class":431},[402,995,947],{"class":411},[402,997,998],{"class":431},"22",[402,1000,978],{"class":407},[402,1002,1003],{"class":411},"; ",[402,1005,1006],{"class":431},"contain",[402,1008,1009],{"class":411},": layout; }   ",[402,1011,1012],{"class":635},"\u002F* async rail cannot push the footer *\u002F\n",[402,1014,1015],{"class":153,"line":473},[402,1016,1017],{"class":635},"\u002F* trade-off: min-height reserves space even when content is shorter, leaving\n",[402,1019,1020],{"class":153,"line":499},[402,1021,1022],{"class":635},"   gaps on sparse products. Measure the p75 content height, not the maximum. *\u002F\n",[15,1024,1025,1026,1029],{},"Expected outcome: route-level CLS for templated views drops below ",[30,1027,1028],{},"0.05"," in most cases.",[388,1031,1033],{"id":1032},"_4-keep-the-persistent-shell-stable-across-routes","4. Keep the persistent shell stable across routes",[15,1035,1036,1037,1040],{},"Give headers, banners and navigation a fixed height on every route, or animate their height changes with ",[30,1038,1039],{},"transform"," rather than layout properties. A promo bar that appears only on product routes should occupy a reserved slot everywhere, even if empty.",[15,1042,1043],{},"Expected outcome: shifts attributed to shell components disappear from the per-route report.",[15,1045,1046],{},[45,1047,52,1050,52,1053,52,1056,52,1058,52,1061,52,1063,52,1068,52,1075,52,1081,52,1085,52,1089,52,1094,52,1098,52,1102,52,1106,52,1110,52,1114,52,1118,52,1121,52],{"viewBox":1048,"width":48,"role":49,"ariaLabel":1049,"style":51},"0 0 760 216","Bar chart of p75 CLS per route showing the item route dropping below the 0.1 threshold after reserving space.",[54,1051],{"className":1052,"x":58,"y":58,"width":48,"height":48,"fill":59},[57],[61,1054,1055],{},"Per-route CLS before and after placeholder fixes",[65,1057,1049],{},[54,1059],{"x":69,"y":69,"width":70,"height":1060,"rx":72,"fill":73,"stroke":74,"style":75},"214",[77,1062,1055],{"x":79,"y":80,"fill":74,"style":81},[153,1064],{"x1":1065,"y1":305,"x2":1065,"y2":1066,"stroke":117,"strokeWidth":307,"strokeDashArray":1067,"style":310},"412.7","194.0",[309,309],[77,1069,1074],{"x":1070,"y":1071,"fill":74,"style":1072,"textAnchor":1073},"117.0","92.0","font-size:13px","end","\u002Fitem\u002F:id before",[54,1076],{"x":1077,"y":318,"width":1078,"height":1079,"rx":93,"fill":116,"stroke":117,"style":1080},"129.0","539.0","19","fill-opacity:0.7;stroke-opacity:0.9",[77,1082,151],{"x":1083,"y":1071,"fill":74,"style":1084},"674.0","font-size:12px;font-weight:600",[77,1086,1088],{"x":1070,"y":1087,"fill":74,"style":1072,"textAnchor":1073},"123.0","\u002Fitem\u002F:id after",[54,1090],{"x":1077,"y":1091,"width":1092,"height":1079,"rx":93,"fill":94,"stroke":94,"style":1093},"109.0","85.1","fill-opacity:0.55;stroke-opacity:0.9",[77,1095,1097],{"x":1096,"y":1087,"fill":74,"style":1084},"220.1","0.03",[77,1099,1101],{"x":1070,"y":1100,"fill":74,"style":1072,"textAnchor":1073},"154.0","\u002Fsearch before",[54,1103],{"x":1077,"y":1104,"width":1105,"height":1079,"rx":93,"fill":94,"stroke":94,"style":1093},"140.0","170.2",[77,1107,1109],{"x":1108,"y":1100,"fill":74,"style":1084},"305.2","0.06",[77,1111,1113],{"x":1070,"y":1112,"fill":74,"style":1072,"textAnchor":1073},"185.0","\u002Fsearch after",[54,1115],{"x":1077,"y":1116,"width":1117,"height":1079,"rx":93,"fill":94,"stroke":94,"style":1093},"171.0","56.7",[77,1119,136],{"x":1120,"y":1112,"fill":74,"style":1084},"191.8",[77,1122,1123],{"x":1065,"y":379,"fill":380,"style":381,"textAnchor":101},"CLS 0.1",[194,1125,1127],{"id":1126},"verification","Verification",[15,1129,1130,1131,1133],{},"Run a scripted session that visits five routes and assert that no route's windowed CLS exceeds ",[30,1132,1028],{}," in the lab. In the field, chart CLS against pages-per-session again: after the fix the curve should flatten, because more routes no longer means more chances for a bad window. The landing page's official CLS in CrUX should fall with it, typically over the 28-day CrUX window.",[194,1135,1137],{"id":1136},"patterns-that-generate-shifts-only-in-spas","Patterns That Generate Shifts Only in SPAs",[15,1139,1140],{},"Some layout shifts are unique to client-side routing and never appear in a multi-page version of the same site, which is why they survive lab testing that loads each URL directly.",[15,1142,1143,1146],{},[205,1144,1145],{},"Scroll restoration racing the render."," Routers restore the previous scroll position on back navigation. If the restored position is applied before the route's content has its full height, the browser clamps the scroll, then the content grows and the router re-applies it. Each correction moves visible content. Restore scroll only after the route's data has rendered, or reserve the height first.",[15,1148,1149,1152,1153,1156],{},[205,1150,1151],{},"Keyed list transitions."," Animating list items in and out with ",[30,1154,1155],{},"\u003CTransitionGroup>"," or Framer Motion's layout animations can move siblings using layout properties. When the animation outlives the 500ms input exclusion, every frame of it counts. Prefer transform-based FLIP animations, which shift visually without moving layout boxes.",[15,1158,1159,1162,1163,1166],{},[205,1160,1161],{},"Lazy route chunks with their own CSS."," A route whose CSS ships in its own chunk can render one frame with unstyled or partially styled content if the stylesheet arrives after the component. In Vite and webpack this shows up as a flash of large, unstyled text that then collapses. Ensure route CSS is loaded before the route component renders — most bundlers' route-level CSS injection does this, but manual ",[30,1164,1165],{},"import()"," of a component outside the router may not.",[15,1168,1169,1172,1173,1176],{},[205,1170,1171],{},"Persisted layout state."," A sidebar that remembers \"collapsed\" in ",[30,1174,1175],{},"localStorage"," renders expanded on server-rendered landing pages and collapsed after hydration. In an SPA this only happens once per session, but on the landing route, where CrUX attributes it.",[194,1178,1180],{"id":1179},"faq","FAQ",[1182,1183,1186,1190],"details",{"className":1184},[1185],"faq-item",[1187,1188,1189],"summary",{},"Does the official CLS ever reset on soft navigations?",[15,1191,1192],{},"Not in the standard definition. The experimental Soft Navigations API in Chromium proposes resetting metrics at detected soft navigations, but it is not what CrUX uses today. Your own per-route attribution is what makes the data actionable; the official number will improve as a consequence of fixing the routes it reveals.",[1182,1194,1196,1199],{"className":1195},[1185],[1187,1197,1198],{},"Should I count shifts that happen during route transitions at all?",[15,1200,1201],{},"Yes. Users see them, and they are real layout instability. The point of per-route attribution is not to exclude them but to charge them to the route whose rendering caused them, so the owning team sees and fixes them.",[194,1203,1205],{"id":1204},"related","Related",[199,1207,1208,1215,1222],{},[202,1209,1210,1214],{},[19,1211,1213],{"href":1212},"\u002Fcore-web-vitals-measurement\u002Freducing-cumulative-layout-shift-cls\u002F","Reducing Cumulative Layout Shift (CLS)"," — the full set of shift sources and fixes.",[202,1216,1217,1221],{},[19,1218,1220],{"href":1219},"\u002Fcore-web-vitals-measurement\u002Freducing-cumulative-layout-shift-cls\u002Ffinding-layout-shift-sources-in-devtools\u002F","Finding layout shift sources in DevTools"," — lab techniques for the routes this method flags.",[202,1223,1224,1228],{},[19,1225,1227],{"href":1226},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fattributing-inp-to-the-right-route-in-an-spa\u002F","Attributing INP to the right route in an SPA"," — the same route-history approach applied to interactions.",[1230,1231,1233],"script",{"type":1232},"application\u002Fld+json","\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"HowTo\",\n  \"name\": \"Why Your SPA's CLS Gets Worse the Longer Users Stay — and How to Attribute It\",\n  \"description\": \"How CLS session windows span client-side navigations, why long SPA sessions look worse, and how to measure shifts per route.\",\n  \"step\": [\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 1,\n      \"name\": \"Attribute every shift to the route that was active\",\n      \"text\": \"Expected outcome: a per-route list of shifts with a responsible component name.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 2,\n      \"name\": \"Compute the CLS window maximum per route\",\n      \"text\": \"Apply the official windowing rules within each route's shifts, starting fresh at each navigation.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 3,\n      \"name\": \"Reserve space for route content before data arrives\",\n      \"text\": \"Most SPA route shifts come from placeholders with the wrong size.\"\n    },\n    {\n      \"@type\": \"HowToStep\",\n      \"position\": 4,\n      \"name\": \"Keep the persistent shell stable across routes\",\n      \"text\": \"Give headers, banners and navigation a fixed height on every route, or animate their height changes with transform rather than layout properties.\"\n    }\n  ]\n}\n",[1230,1235,1236],{"type":1232},"\n{\n  \"@context\": \"https:\u002F\u002Fschema.org\",\n  \"@type\": \"TechArticle\",\n  \"headline\": \"Why Your SPA's CLS Gets Worse the Longer Users Stay — and How to Attribute It\",\n  \"description\": \"How CLS session windows span client-side navigations, why long SPA sessions look worse, and how to measure shifts per route.\",\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\u002Fwhy-spa-cls-accumulates-across-routes\u002F\"\n  }\n}\n",[1230,1238,1239],{"type":1232},"\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\": \"Why SPA CLS Accumulates Across Routes\",\n      \"item\": \"https:\u002F\u002Ffrontend-performance.com\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes\u002F\"\n    }\n  ]\n}\n",[1241,1242,1243],"style",{},"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 .sZ8jY, html code.shiki .sZ8jY{--shiki-default:#032563;--shiki-dark:#ADDCFF;--shiki-light:#032563}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 .sjfSM, html code.shiki .sjfSM{--shiki-default:#66707B;--shiki-dark:#BDC4CC;--shiki-light:#66707B}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":398,"searchDepth":425,"depth":425,"links":1245},[1246,1247,1248,1254,1255,1256,1257],{"id":196,"depth":425,"text":197},{"id":253,"depth":425,"text":254},{"id":385,"depth":425,"text":386,"children":1249},[1250,1251,1252,1253],{"id":390,"depth":448,"text":391},{"id":654,"depth":448,"text":655},{"id":914,"depth":448,"text":915},{"id":1032,"depth":448,"text":1033},{"id":1126,"depth":425,"text":1127},{"id":1136,"depth":425,"text":1137},{"id":1179,"depth":425,"text":1180},{"id":1204,"depth":425,"text":1205},"How CLS session windows span client-side navigations, why long SPA sessions look worse, and how to measure shifts per route.","md",{"slug":1261,"type":1262,"breadcrumb":1263,"datePublished":1271,"dateModified":1271},"why-spa-cls-accumulates-across-routes","article",[1264,1267,1268,1269],{"name":1265,"url":1266},"Home","\u002F",{"name":27,"url":26},{"name":22,"url":21},{"name":5,"url":1270},"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes\u002F","2026-10-06",true,"\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes",{"title":1275,"description":1276},"Why SPA CLS Accumulates Across Routes (and the Fix)","Single-page apps report one CLS for a whole session. Learn how session windows work across routes and how to attribute layout shifts to the view that caused them.","core-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Fwhy-spa-cls-accumulates-across-routes\u002Findex","6ZaC7WdRxmfTdaiyfce9Lf70OxHWqhYKZFlYPfPB0gM",[1280,1284],{"title":1281,"path":1282,"stem":1283},"Tracking Route Transition Time with User Timing","\u002Fcore-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing","core-web-vitals-measurement\u002Fsoft-navigations-and-spa-metrics\u002Ftracking-route-transition-time-with-user-timing\u002Findex",{"title":1285,"path":1286,"stem":1287},"Understanding Core Web Vitals Thresholds","\u002Fcore-web-vitals-measurement\u002Funderstanding-core-web-vitals-thresholds","core-web-vitals-measurement\u002Funderstanding-core-web-vitals-thresholds\u002Findex",1791308074539]