{"id":8469,"date":"2026-02-06T10:52:02","date_gmt":"2026-02-06T15:52:02","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8469"},"modified":"2026-02-06T10:52:03","modified_gmt":"2026-02-06T15:52:03","slug":"the-browser-hates-surprises","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/the-browser-hates-surprises\/","title":{"rendered":"The Browser Hates Surprises"},"content":{"rendered":"\n<p>We often treat the browser like a canvas \u2014 a blank slate waiting for us to paint pixels. But this mental model is flawed. The browser isn&#8217;t really a painter; it\u2019s a <strong>constraint solver<\/strong>.<\/p>\n\n\n\n<p>Every time you load a page, you enter a high-speed negotiation. You provide the rules (HTML &amp; CSS), and the browser calculates the geometry. When you give it all the math upfront, the result feels magical: a stable, buttery-smooth experience.<\/p>\n\n\n\n<p>But when we force the browser to recalculate that geometry mid-stream \u2014 because an image loaded late, a scrollbar popped in, or a font swapped \u2014 we break the spell.<\/p>\n\n\n\n<p>We know this phenomenon as &#8220;Cumulative Layout Shift&#8221; (CLS), but really, it&#8217;s just <strong>jank<\/strong>. And jank doesn&#8217;t happen by accident. It happens because we <em>surprised<\/em> the browser.<\/p>\n\n\n\n<p>To fix this, we need to stop fighting the rendering engine and start orchestrating it. But first, seeing is believing. Let&#8217;s look at exactly what it looks like when we get it wrong.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A &#8220;Hostile&#8221; Web Site<\/h2>\n\n\n\n<p>I call what we&#8217;re doing in the demo below &#8220;hostile&#8221; because the code is indifferent to the browser&#8217;s needs. It treats the rendering engine like a bucket we can dump data into whenever it arrives.<\/p>\n\n\n\n<p>In the code below, you will see four distinct &#8220;surprises&#8221; that break the user experience:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>The Sticky Header Collision:<\/strong> When you click &#8220;Jump to Section 2,&#8221; the browser scrolls correctly to the top of the element, but the title gets buried behind the fixed header.<\/li>\n\n\n\n<li><strong>Popcorn Loading:<\/strong> The text and images load independently. The layout jumps once for the text, and again for the image.<\/li>\n\n\n\n<li><strong>The Image Shift:<\/strong> The image isn&#8217;t reserved space. It pushes the text down when it finally arrives.<\/li>\n\n\n\n<li><strong>The Scrollbar Shift:<\/strong> When the content grows long enough, a physical scrollbar pops in (on Windows\/Linux), shifting the entire UI to the left.<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">\ud83d\udee0 The &#8220;Before&#8221; Demo<\/h3>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_019c2a0f-26c2-74f7-b94b-e176611e231b\" src=\"\/\/codepen.io\/editor\/anon\/embed\/019c2a0f-26c2-74f7-b94b-e176611e231b?height=950&amp;theme-id=1&amp;slug-hash=019c2a0f-26c2-74f7-b94b-e176611e231b&amp;default-tab=result\" height=\"950\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed 019c2a0f-26c2-74f7-b94b-e176611e231b\" title=\"CodePen Embed 019c2a0f-26c2-74f7-b94b-e176611e231b\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p class=\"learn-more\">Note that we&#8217;re just <strong>faking<em> <\/em><\/strong>this hostile behavior with <code>setTimeout<\/code>, but it&#8217;s entirely plausible that real world websites experience these conditions naturally. Data can take time to arrive from APIs. Media can be slow to load. The amount of content can push a page to needing to scroll when it didn&#8217;t before.  <\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Theory (Why This Happens)<\/h2>\n\n\n\n<p>Why did that feel so broken? It wasn&#8217;t just &#8220;slow internet.&#8221; It was a failure of negotiation.<\/p>\n\n\n\n<p>To fix this, we need to understand how the browser thinks. The browser rendering engine has a strict pipeline:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Parse<\/strong> (Read HTML\/CSS)<\/li>\n\n\n\n<li><strong>Layout<\/strong> (Calculate the geometry of every box)<\/li>\n\n\n\n<li><strong>Paint<\/strong> (Fill in the pixels)<\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\">The &#8220;Streaming Buffer&#8221; Mistake<\/h3>\n\n\n\n<p>In the code above, we threw the text at the browser, <em>then<\/em> the image, <em>then<\/em> the scrollbar.<\/p>\n\n\n\n<p>Every time we did that, we forced the browser to stop <strong>Painting<\/strong>, go back to <strong>Layout<\/strong>, recalculate the math for the page, and then <strong>Paint<\/strong> again. This is called a <strong>Reflow<\/strong>. Reflows are expensive for the CPU, but they are disastrous for the user experience because they physically move pixels that the user is currently looking at.<\/p>\n\n\n\n<p>We need to move from <strong>Reactive Rendering<\/strong> (reacting to data arrival) to <strong>Orchestrated Rendering<\/strong> (planning for data arrival).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Solutions<\/h2>\n\n\n\n<p>We are going to make four specific negotiations with the browser to ensure stability.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. The Coordinate Negotiation<\/h3>\n\n\n\n<p>The browser isn&#8217;t &#8220;wrong&#8221; when it scrolls your title behind the sticky header. It is scrolling to the exact mathematical top of the element. The problem is that our header exists <em>outside<\/em> the normal document flow.<\/p>\n\n\n\n<p>We need to update the browser\u2019s metadata regarding that element&#8217;s landing zone.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-pseudo\">:target<\/span> {\n\u00a0\u00a0<span class=\"hljs-comment\">\/* \"Hey browser, when you scroll here, leave 6rem of space against the top\" *\/<\/span>\n\u00a0\u00a0<span class=\"hljs-attribute\">scroll-margin-top<\/span>: <span class=\"hljs-number\">6rem<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">2. The Space Negotiation<\/h3>\n\n\n\n<p>On Windows and Linux, standard scrollbars take up physical space (usually ~17px). This happens on macOS too, but only when users have the <a href=\"https:\/\/www.macrumors.com\/how-to\/make-scroll-bars-always-visible\/\">Show scroll bars: Always<\/a> option selected, which is not the default. When a scrollbar appears, the available width of the viewport changes, forcing a global recalculation that shifts centered content to the left.<\/p>\n\n\n\n<p>We have two ways to solve this layout mutation.<\/p>\n\n\n\n<h4 class=\"wp-block-heading\">Option A: The Classic Fix (Maximum Compatibility)<\/h4>\n\n\n\n<p>The most reliable method is to force the scrollbar track to be visible at all times, even on short pages.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">html<\/span> {\n\u00a0\u00a0<span class=\"hljs-attribute\">overflow-y<\/span>: scroll;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h4 class=\"wp-block-heading\">Option B: The Modern Fix (Cleaner UI)<\/h4>\n\n\n\n<p>Modern CSS gives us a dedicated property that tells the browser: <em>&#8220;If a scrollbar might exist later, reserve that 17px slot now, but don&#8217;t show an ugly disabled track.&#8221;<\/em><\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">html<\/span> {\n\u00a0\u00a0<span class=\"hljs-attribute\">scrollbar-gutter<\/span>: stable;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">3. The Layout Reservation <\/h3>\n\n\n\n<p>Normally, the browser assumes an image is 0x0 until the file header downloads. By setting an <code>aspect-ratio<\/code> in CSS, we issue a &#8220;reservation ticket.&#8221; We allow the browser to calculate the final bounding box during the <strong>CSS Parse<\/strong> phase \u2014 before the network request is even sent.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css shcb-code-table\"><span class='shcb-loc'><span><span class=\"hljs-selector-tag\">img<\/span> {\n<\/span><\/span><span class='shcb-loc'><span>\u00a0\u00a0<span class=\"hljs-attribute\">width<\/span>: <span class=\"hljs-number\">100%<\/span>;\n<\/span><\/span><span class='shcb-loc'><span>\u00a0\u00a0<span class=\"hljs-attribute\">height<\/span>: auto;\n<\/span><\/span><mark class='shcb-loc'><span>\u00a0\u00a0<span class=\"hljs-attribute\">aspect-ratio<\/span>: <span class=\"hljs-number\">16<\/span> \/ <span class=\"hljs-number\">9<\/span>;\n<\/span><\/mark><span class='shcb-loc'><span>\u00a0\u00a0<span class=\"hljs-attribute\">object-fit<\/span>: cover;\n<\/span><\/span><span class='shcb-loc'><span>}\n<\/span><\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h3 class=\"wp-block-heading\">4. The Orchestration (<strong>Promise.all)<\/strong><\/h3>\n\n\n\n<p>Instead of firing three separate state updates (three separate reflows), we wait for the entire &#8220;scene&#8221; to be ready. We trade a few milliseconds of &#8220;First Paint&#8221; for a UI that arrives fully formed.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Don't just fetch. Orchestrate.<\/span>\n<span class=\"hljs-keyword\">async<\/span> <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-title\">loadScene<\/span>(<span class=\"hljs-params\"><\/span>) <\/span>{\n\u00a0\u00a0<span class=\"hljs-comment\">\/\/ Wait for critical actors to be ready<\/span>\n\u00a0\u00a0<span class=\"hljs-keyword\">const<\/span> results = <span class=\"hljs-keyword\">await<\/span> <span class=\"hljs-built_in\">Promise<\/span>.all(&#91;\n\u00a0\u00a0\u00a0\u00a0fetch(<span class=\"hljs-string\">'\/api\/text'<\/span>),\n\u00a0\u00a0\u00a0\u00a0fetch(<span class=\"hljs-string\">'\/api\/image'<\/span>)\n\u00a0\u00a0]);\n\n\u00a0\u00a0<span class=\"hljs-comment\">\/\/ Update the state ONCE.\u00a0<\/span>\n\u00a0\u00a0<span class=\"hljs-comment\">\/\/ One reflow. One paint.<\/span>\n\u00a0\u00a0setFullScene(results);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<h2 class=\"wp-block-heading\">Phase 4: The &#8220;Stable&#8221; Application<\/h2>\n\n\n\n<p>Here is the exact same application, but negotiated correctly.<\/p>\n\n\n\n<p>Notice how &#8220;calm&#8221; the loading feels. The layout never jumps. The scroll lands exactly where you expect. It feels like a native application because we gave the browser the constraints it needed <em>before<\/em> painting.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">\ud83d\udee0 The &#8220;After&#8221; Demo<\/h3>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_019c2a5c-efd5-751a-974c-d29113e9c0a7\" src=\"\/\/codepen.io\/editor\/anon\/embed\/019c2a5c-efd5-751a-974c-d29113e9c0a7?height=950&amp;theme-id=1&amp;slug-hash=019c2a5c-efd5-751a-974c-d29113e9c0a7&amp;default-tab=result\" height=\"950\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed 019c2a5c-efd5-751a-974c-d29113e9c0a7\" title=\"CodePen Embed 019c2a5c-efd5-751a-974c-d29113e9c0a7\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p>Optimization is not about making things load faster; it is about making them load <strong>calmer<\/strong>.<\/p>\n\n\n\n<p>Every scroll bug, every jumpy image, and every layout shift is a sign that we failed to give the browser the information it needed at the time it needed it.<\/p>\n\n\n\n<p>Stop surprising the browser. Start reserving space.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>To avoid page loading jank, there are things we can do to avoid content from shifting around, even if repainting is still necessary.<\/p>\n","protected":false},"author":43,"featured_media":8500,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[7,3,53,70],"class_list":["post-8469","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-css","tag-javascript","tag-layout","tag-performance"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/party.jpg?fit=2100%2C1270&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8469","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/43"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8469"}],"version-history":[{"count":10,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8469\/revisions"}],"predecessor-version":[{"id":8504,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8469\/revisions\/8504"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8500"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8469"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8469"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8469"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}