The Slow Degradation of a Fast Site
Every web performance story follows a similar arc. You launch a carefully optimized project, celebrate great Lighthouse scores, and then slowly watch them erode as new features, urgent fixes, and third-party scripts accumulate. Smashing Magazine has been through this cycle more than once.
We're a small team of about 12 people, many part-time, and while performance has been a stated goal for nearly a decade, we never had a dedicated performance team. After the 2017 redesign, the site ran well. But by late 2020, the codebase had grown overweight and fragmented. Lighthouse scores on the homepage had dropped to 60–68, and article pages were scoring 40–60 — worse on mobile. Something had to give.
Where We Were: JAMStack After Three Years
The current Smashing site runs on JAMStack. All articles are stored as Markdown files, Sass compiles into CSS, and JavaScript splits into chunks with Webpack. Hugo builds out static pages that are served from an Edge CDN. We originally built with Preact in 2017 but moved to React in 2019, using it alongside APIs for search, comments, authentication, and checkout.
The site is built with progressive enhancement in mind: every article can be read without booting the application. Static content doesn't change much, but dynamic features like membership authentication and the cart require JavaScript. The full build for roughly 2,500 articles takes about 6 minutes, including critical CSS injection, Webpack code splitting, dynamic ad and feature panel inserts, RSS generation, and A/B testing on the edge.
The Trouble Spots
CSS Refactoring
We started a major refactoring effort in early 2020. The site uses a component-based system of Sass modules compiled into CSS — no CSS-in-JS or styled-components. The layout was rebuilt with CSS Grid and Custom Properties in mid-2019, but new advertising spots and product panels required special treatment that made the layout difficult to maintain.
The header and main navigation needed to accommodate dynamic items, and frequently used components like the newsletter box were overdue for a revision. We began refactoring some components with utility-first CSS but never achieved consistent usage across the site.
The JavaScript Bundle
The larger issue was the large JavaScript bundle blocking the main thread for hundreds of milliseconds. It seems excessive for a publishing site, but plenty of scripting happens behind the scenes:
- Components exist in both authenticated and unauthenticated states, so signed-in users see correct pricing and a persistent cart.
- Advertising must load quickly without causing layout shifts, as must native product panels.
- A service worker caches all static assets and previously viewed articles for repeat visits.
All that scripting was draining the reading experience even when loaded late. The turning point came when Harry Roberts ran his Web Performance Masterclass as a Smashing workshop, using our site as a live example of what to fix. We took notes and got to work.
Finding The Real Performance Bottlenecks
Single-score performance assessments are rarely reliable. Web performance is not one number but a distribution of experiences that vary heavily with network conditions and device hardware. Since we cannot control those variables, the practical goal is to maximize the share of fast experiences and minimize the share of slow ones — which requires understanding the current distribution first.
We turned to the Chrome User Experience Report (CrUX) to see how our performance distribution changed over time. The data, which aligns with Core Web Vitals and feeds into Lighthouse, showed a dramatic regression throughout the year, with sharp drops in August and September. When we looked back at our commits from that window, three changes stood out.
First, we launched a new navigation bar used across the entire site. Its menu logic lived inside the app.js bundle, so we extracted that script and served it inline to improve Time To Interactive. At the same time, we replaced our manually maintained critical CSS with an automated generator that produced critical CSS for every template at build time — without realizing how much heavier the output would be. We also adjusted font loading, pushing web fonts harder with preload hints. That backfired: fonts were overprioritized relative to the full stylesheet and delayed content rendering.
We then inspected the JavaScript payload using Webpack Bundle Analyzer and Simon Hearne’s request map. A lightweight article page looked clean: a handful of requests to the CDN, Cookiebot, Google Analytics, and our internal services. But light pages were not an accurate sample of our library. Many of the articles we publish are heavy by nature: they contain animated GIFs, syntax-highlighted code, CodePen embeds, video players, and long comment threads. On those pages, the DOM can explode in size and the main thread becomes overloaded, especially when late-injected ads trigger cascading style recalculations.
So we deliberately audited the heaviest pages we had: the homepage, the longest article we’ve published, a page with many video embeds, and one stuffed with CodePen embeds. The difference was stark; a single article generated 78 requests to Vimeo alone. Digging into DevTools’ Performance panel, we hunted for long tasks (over 50ms) and style recalculation bursts (purple bars) — exposing expensive JavaScript and style invalidations from dynamic DOM injections. Font loading showed notable repaint costs, and chunks were still blocking the main thread. With a Lighthouse baseline taken under pessimistic mobile conditions (slow 3G, 400ms RTT, 400kbps), the heaviest pages scored full red, with complaints about unused JS/CSS, offscreen images, and asset sizes.
Right-Sizing the Head
While our list of problems was long, we started with something that looked far more mundane: the order of assets in the <head>. Being deliberate about the source order of critical CSS, resource hints, web fonts, scripts, and the full stylesheet turned out to matter far more than expected. We effectively reversed much of our previous arrangement — placing critical CSS before asynchronous scripts and all preloaded assets — and split preconnects and preloads by template and file type, requesting heavy resources early only on pages that genuinely need them. Carefully pruning competing preloaded assets and tuning the <head> order bought us an immediate 3–4 point Lighthouse gain across the board.
Critical CSS: Back to Handcrafted
Automated critical CSS generation sounded like the right upgrade. Back in 2017, we had manually authored styles for the first 1000 pixels of vertical viewport per template, which was arduous and hard to maintain. When we automated that workflow around July–August of last year, the spike in CrUX data appeared almost instantly. Configuration was a recurring pain point — adding or removing particular rules (such as cookie-consent prompts that only appear after the consent script initializes) was an uphill battle, and the generated output was verbose.
When we introduced major layout changes in October, we hit the same generation problems again. As an experiment, we went back to hand-authoring critical CSS. Spending several days with code coverage tools on key pages, we grouped rules manually and stripped obsolete and duplicated styles — a much-needed housecleaning, since many rules from 2017–2018 were no longer needed. We now have three finished manual critical CSS files, with three more in progress:
critical-homepage-manual.css(8.2 KB, Brotlified)critical-article-manual.css(8 KB, Brotlified)critical-articles-manual.css(6 KB, Brotlified)- Work pending on files for books, events, and the job board
The files are inlined in the head of each template, though they are still duplicated inside our monolithonic CSS bundle, which contains every style used anywhere on the site. The next step is splitting that monolithic stylesheet into per-area packages, so a reader on an article page to avoid loading job-board or book-page styles. Visiting a new area would yield a quick paint from that area’s critical CSS, followed by an asynchronous load of its remaining styles.
In the end, the handcrafted files were only about 14% smaller than their automated predecessors. The real win, though, was correctness: they contained all required rules in proper order, free of duplicates and overriding styles. That change alone moved Lighthouse another 3–4 points. Progress was being made, but the harder optimizations were still ahead of us.
Rethinking Font Delivery for Returning Readers
On a content-heavy site like Smashing Magazine, readers frequently move between articles and return hours or days later. This recurring pattern exposed a flaw in the standard font-display approach: returning readers experienced visible flashes between fallback and web fonts, even when fonts were properly cached. This was anything but a smooth experience.
The site relies on two core typefaces: Mija (for headings, in Regular and Bold weights) and Elena (for body copy, in Regular, Italic, and Bold weights). The unused Bold Italic weight was dropped years ago, and the remaining fonts were subset by removing unused characters and Unicode ranges.
Because the content is mostly text, the Largest Contentful Paint is typically either the first paragraph of an article or the author's photo. This meant that getting the first paragraph rendered quickly in a fallback font, then swapping to the web font with minimal disruption, was a top priority. We set out with four goals:
- Render text immediately with a fallback font on first visit;
- Match fallback and web font metrics to keep layout shifts low;
- Load all web fonts asynchronously and apply them together (max. 1 reflow);
- Render text directly in web fonts on later visits, avoiding any flashing or reflows.
Trying font-display: swap first seemed like the simplest option. But for readers who browse multiple pages, this led to constant flickering across the six fonts in use, and there was no way to group requests or repaints with this approach alone.
Another idea was to serve a fallback font on the first visit, cache all fonts asynchronously, and only serve web fonts directly from cache on subsequent visits. The problem was that a large number of readers come from search engines and will only see one page — showing them an article in a system font only was not acceptable.
A Two-Stage Approach, Then a Simplification
Since 2017, we had been using the Two-Stage-Render approach. The idea: render first with a minimal subset of web fonts, then swap in the full font family. We created tiny subsets of Mija Bold and Elena Regular — the most used weights — containing Latin characters, punctuation, numbers, and a few special characters. These ElenaInitial.woff2 and MijaInitial.woff2 files were often just 10–15 KB in size and served during the first stage of rendering.
This was done via the Font Loading API, which tracks which fonts have loaded successfully. Adding a .wf-loaded-stage1 class to the body switches the content to those small font files. Once the reader can begin reading, the full weights are loaded asynchronously, and .wf-loaded-stage2 is added to the body to swap in the complete fonts.
These swaps happen randomly by default — whichever font arrives through the network first wins. That can be disruptive when you're already reading. So rather than leave it to the browser, we grouped the repaints to minimize reflow impact.
But there was a catch: if the first small subset didn't arrive quickly from the network, the browser fell back to a system font (Arial in our stack) after a 3-second timeout. Then it would switch to ElenaInitial or MijaInitial, only to later switch again to full Elena or Mija. That was too much flashing. We considered conditionally skipping the first stage for slow connections via the Network Information API, but ultimately decided to remove the intermediate stage entirely.
In October, we removed the subsets and the intermediate stage altogether. Now, when all weights of both Elena and Mija are downloaded and ready, stage 2 is initiated and everything repaints at once. To make that reflow less noticeable, we spent time matching fallback and web font metrics — adjusting font sizes and line heights, especially for elements in the initially visible portion of the page.
For this we relied on font-style-matcher and, admittedly, a few magic numbers. This is also why we chose -apple-system and Arial as global fallback fonts. San Francisco (rendered via -apple-system) looked a bit nicer, but if it wasn't available, we picked Arial because it's widely spread across most OSes.
The CSS ended up looking like this:
.article__summary {
font-family: -apple-system,Arial,BlinkMacSystemFont,Roboto Slab,Droid Serif,Segoe UI,Ubuntu,Cantarell,Georgia,sans-serif;
font-style: italic;
/* Warning: magic numbers ahead! */
/* San Francisco Italic and Arial Italic have larger x-height, compared to Elena */
font-size: 0.9213em;
line-height: 1.487em;
}
.wf-loaded-stage2 .article__summary {
font-family: Elena,sans-serif;
font-size: 1em; /* Original font-size for Elena Italic */
line-height: 1.55em; /* Original line-height for Elena Italic */
}
Once downloaded, fonts are stored in the service worker's cache. On subsequent visits, we check the cache first; if the fonts are there, they're fetched from the service worker and applied immediately. Otherwise, the fallback-to-web-font switcheroo starts over.
This approach reduced reflows to just one on reasonably fast connections, while keeping fonts reliably cached. We hope to eventually replace the magic numbers with f-mods.
Dissecting the JavaScript Bundle
The DevTools Performance panel told a clear story: eight Long Tasks were running between 70ms and 580ms, blocking the main thread. The main culprits were:
- uc.js, the cookie prompt script (70ms);
- Style recalculations from the incoming full.css file (176ms);
- Advertising scripts on the load event plus related style recalculations (276ms);
- Web font switching and style recalculations (290ms);
- app.js evaluation (580ms).
We prioritized the longest tasks first.
The 290ms task came from expensive layout recalculations triggered by the fallback-to-web-font switch. Removing stage one from the font loading alone recovered about 80ms. That wasn't enough, given the 50ms budget, so we dug further.
The recalculations were largely caused by significant differences between fallback and web fonts. By matching line heights and font sizes between them, we reduced situations where a line wrapped differently between fonts, which caused major page geometry changes and layout shifts. We experimented with letter-spacing and word-spacing, but those didn't work well.
These changes gained us another 50–80ms, but we couldn't get below 120ms without rendering content in the fallback font first. This mostly affects first-time visitors; returning readers get fonts directly from the service worker cache and avoid costly reflows.
A key observation: most Long Tasks weren't caused by heavy JavaScript but by Layout Recalculations and CSS parsing. That meant significant CSS cleanup was in order, particularly where styles were being overwritten. This was good news in a way, but the work is ongoing. We eliminated two Long Tasks completely, but several remain, occasionally pushing past the 50ms threshold.
Splitting the Monolith
The bigger problem was the main JavaScript bundle, which occupied the main thread for 580ms, mostly booting up app.js — containing React, Redux, Lodash, and a Webpack module loader. The only realistic path was to break it apart using Webpack code-splitting, creating smaller chunks of about 30KB each.
We cleaned up package.json, upgraded all production dependencies to current versions, adjusted the browserlistrc configuration to target the latest two browser versions, upgraded to the newest Webpack and Babel releases, switched to Terser for minification, and compiled for ES2017 (per browserlistrc). We also used BabelEsmPlugin to generate modern versions of existing dependencies. Finally, we added prefetch links in the header for necessary script chunks and migrated the service worker to Workbox via the workbox-webpack-plugin.
One long-standing issue traced back to the navigation redesign in mid-2020. The old navigation was static HTML with minimal CSS, but the new one required JavaScript for opening and closing menus on mobile and desktop. This caused rage clicks when clicking the menu did nothing, and it hurt Time-To-Interactive scores in Lighthouse.
We extracted the navigation script from the main bundle as a standalone file. Other rarely-used scripts — for syntax highlighting, tables, video embeds, and code embeds — were also removed from the main bundle and loaded granularly, only when needed.
For months, we didn't notice that even after removing the navigation script from the bundle, it only loaded after the entire app.js bundle had been evaluated. That didn't help Time-To-Interactive at all. Preloading nav.js and deferring it to execute in DOM order saved another 100ms.
With all these changes combined, we brought the task down to around 220ms.
We've made solid progress but still have ground to cover. React and Webpack optimizations remain on the to-do list. As it stands, we still have three major Long Tasks: the font switch (120ms), app.js execution (220ms), and style recalculations from the full CSS file (140ms). The next target is cleaning up and splitting that monolithic stylesheet.
It's also important to note that these are best-case results. A typical article page can include numerous code embeds, video embeds, third-party scripts, and a reader's browser extensions — all of which introduce further variables.
Taming Third-Party Scripts
Third-party scripts weren't a huge problem from the start, but as they accumulated, they started dragging performance down — especially on article pages, which make up the bulk of the site's content. Video embedding scripts were the worst offenders, followed by syntax highlighting, advertising, promo panels, and external iframes.
All of these scripts are deferred until after DOMContentLoaded, but when they finally run, they still cause significant work on the main thread. The first fix was allocating proper space to every asset injected after the initial render. That meant setting width and height on all advertising images and styling code snippets properly. Because the scripts were deferred, the new styles were invalidating existing ones, which triggered massive layout shifts for each code snippet displayed. Adding the needed styles to the critical CSS on article pages resolved that.
For images, the team re-established an optimization strategy, preferring AVIF or WebP (still in progress). Images below the 1000px height threshold use native lazy loading via <img loading=lazy>, while those at the top are prioritized with <img loading=eager>. The same approach applies to third-party embeds.
Some dynamic parts were replaced with static ones. For example, the note about an article being saved for offline reading used to appear dynamically after the article was added to the service worker's cache. Now it appears statically — a slightly optimistic assumption that all modern browsers support it.
At the time of writing, facades for code and video embeds are in preparation. Offscreen images will also get the decoding=async attribute, letting the browser decide when and how to load them asynchronously and in parallel.
To make sure images always carry width and height attributes, the team modified Harry Roberts' snippet and Tim Kadlec's diagnostics CSS to flag any image served incorrectly. That tooling runs in development and editing, not production.
Slow-Motion Loading Audits
One recurring technique was slow-motion loading: adding a simple line to the diagnostics CSS that gives every element a visible outline, then recording the page on slow and fast connections. Replaying the video at reduced speed, moving back and forward, makes it easy to spot where massive layout shifts occur.
* {
outline: 3px solid red
}
* { outline: 3px red } and observing the boxes as the browser is rendering the page. (Large preview)Here's a recording of a page loading on a fast connection:
And here's the playback being studied to see what happens with the layout:
This audit method quickly reveals what's off and where heavy recalculation costs happen. Adjusting line-height and font-size on headings can go a long way toward avoiding large shifts. These simple changes alone lifted the video-heaviest article by 25 Lighthouse points, with smaller gains on pages with code embeds.
Newer Techniques Applied
Beyond the basics, the team adopted several newer capabilities. AVIF by default is planned for serving images, but not quite there yet — many images come from Cloudinary, which has beta AVIF support, but others are served directly from the CDN with no on-the-fly generation logic. That would require a manual process for now.
Offscreen components like the footer, comments section, and panels below the 1000px threshold use content-visibility: auto for lazy rendering after the visible portion of each page is painted.
Experiments with link rel="prefetch" and link rel="prerender" (NoPush prefetch) are underway for likely navigation targets — for instance, prefetching assets for the first articles on the front page (still in discussion).
Author images and key per-page assets — like the dancing cat navigation images and author image shadows — are preloaded to reduce the Largest Contentful Paint. They're only preloaded on screens larger than 800px, though the Network Information API is being considered as a more precise alternative.
Full CSS and critical CSS files were shrunk by removing legacy code, refactoring components, and finally dropping the text-shadow trick previously used for underlines (now handled by text-decoration-skip-ink and text-decoration-thickness).
What Still Needs Work
After substantial effort on minor and major changes, desktop scores improved significantly and mobile saw a noticeable boost. At the time of writing, articles score between 90 and 100 on desktop Lighthouse, but only around 65-80 on mobile.
The mobile score suffers from poor Time to Interactive and Total Blocking Time, caused by app boot and the full CSS file size. Next steps include further reducing CSS size by breaking it into modules — like JavaScript — loading parts for checkout, job board, and books/eBooks only when needed.
More bundling experimentation on mobile is planned to reduce the impact of app.js, though it looks non-trivial. Other items on the list: alternatives to the cookie prompt solution, rebuilding containers with CSS clamp(), replacing the padding-bottom ratio technique with aspect-ratio, and serving as many images as possible in AVIF.
Performance comes down to the sum of fine details — each one small, but together they make or break the user's experience. The team remains committed to improving accessibility and content quality alongside performance, and welcomes feedback in the article comments.
Further Reading
- How To Monitor And Optimize Google Core Web Vitals
- How To Hack Your Google Lighthouse Scores In 2024
- CSS Scroll Snapping Aligned With Global Page Layout: A Full-Width Slider Case Study
- Performance Game Changer: Browser Back/Forward Cache




