Why performance is hard to get right
The median page on mobile now weighs around 1.5MB, with JavaScript and images making up the bulk of that payload. Network latency, CPU limits, render-blocking patterns and third-party scripts all compound the problem. Users consistently rank speed at the top of their UX hierarchy, and it is easy to see why: until a page finishes loading, they can neither use it nor appreciate it.
The difficulty is rarely in knowing that performance matters; it is in knowing where to start. That is where tooling comes in.
Lighthouse as the starting point
Lighthouse, built into Chrome DevTools, audits a site and returns concrete hints for improvement. Recent additions to its load-performance audits are aimed squarely at everyday development workflows.
The Oodles Theater app, a demo that showcases interactive Google Doodles and a couple of games, was built with these audits in mind. The optimization work began with a Lighthouse report, and the initial results were stark.
On a 3G connection, users faced a 15-second wait before the first meaningful paint or before the app became interactive. The overall performance score sat at 23, with the page weighing in at about 3.4MB. Lighthouse flagged a long list of issues, and the first challenge became clear: find what could be cut without degrading the experience.
Removing What Isn’t Needed
The first pass at optimizing any site is deleting what doesn’t need to be sent at all. Whitespace and comments are the obvious candidates, and Lighthouse has a dedicated audit for unminified CSS and JavaScript. In this project, minification came through the UglifyJS webpack plugin during the existing build step. Compression is equally low-risk: the site was hosted on Firebase Hosting, which enables gzip by default. Newer compression options like Brotli and Zopfli are also worth exploring when you need to shave off more.
A related angle is caching. The inefficient cache policy audit flags resources that get re-fetched even though they haven't changed. Setting a max-age expiration header ensures returning visitors reuse previously downloaded files. The goal is to cache as much as is safely possible for as long as possible, and to keep validation tokens around for resources that do update.
Tracking Down Unused Code
Dead code is a subtler problem. The project once relied on the Material Components library for prototyping, but when the UI was redesigned to a custom look, importing that library fell by the wayside—even though it had stayed in the bundle. The code coverage tooling in DevTools exposed the issue during both load time and runtime: over 95% of the CSS was unused, and a large portion of JavaScript was too. Lighthouse’s unused CSS audit put a figure on it—a potential saving of more than 400 kb.
Removing the library was a small commit on paper, but one with oversized effects. The CSS bundle shrank 20-fold, the performance score went up, and Time to Interactive improved. No removal is risk-free, though; the remnant 5% of CSS was still in use, styling the small arrows on a doodle slider. The styles were trivially small, so it was easy to check for regressions with a solid testing workflow and then re-implement them directly in the buttons.
Slimming Down the Payload
Size matters, particularly for mobile users with finite data plans. Lighthouse’s enormous network payload audit drew attention to over 3 mb of code being dispatched to the client. The biggest offender was a JavaScript vendor bundle weighing 2 mb uncompressed—a red flag that also appeared in webpack output. The fastest request is the one that is never made, so each asset deserves scrutiny for whether it belongs in the initial load, or if it can be deferred, lazy-loaded, or handled in idle time.
For JavaScript-heavy bundles, a suite of auditing tools makes this job easier:
- webpack-bundle-analyzer surfaced the
unicodedependency, a 1.6 mb parsed behemoth. - The Import Cost plugin for Visual Studio Code visualized import costs inline, revealing which component in the codebase pulled in that dependency.
- BundlePhobia provided size estimates on npm packages, helping identify a drop-in replacement for the
slugmodule at 2.2 kb.
Between this change and other bundle-trimming tweaks, project weight dropped by 2.1 mb. Accounting for minification and gzipping, that yielded a 65% improvement overall.
Cutting JavaScript Boot Time
Network size alone isn’t the whole story. JavaScript is the costliest resource you can ship, because the mobile browser must download, parse, compile, and execute it, all before the interface is interactive. These phases that complete in a moment on a desktop may take five to ten times longer on a median phone. The Lighthouse boot-up time audit said the app was spending 1.8 seconds in this phase, the result of statically importing all routes and components into one monolithic bundle.
Code splitting is the countermeasure. Rather than sending a whole pizza’s worth of JavaScript, serve one slice as the user progresses. Route- and component-level splitting works with React and React Loadable, as well as Vue.js, Angular, Polymer, and Preact. Migrating the app from static to dynamic imports enabled async lazy loading as components were needed. Bundle sizes shrank and boot-up time fell to 0.78 seconds—a 56% faster startup. If building large JavaScript experiences is unavoidable, consider further strategies such as tree shaking and the tips in the webpack-libs-optimizations repo.
Handling Images Properly
Heavy use of images sank the audit—all three image checks were a miss: unoptimized images, incorrect sizing, and missing out on more efficient formats. Optimizing in a one-off pass is possible with drag-and-drop tools like ImageOptim or XNConvert. But automated optimization in the build process is more sustainable; imagemin ensures every image added later is processed. CDNs like Akamai and third-party services such as Cloudinary, Fastly, and Uploadcare bundle it in. When cost or latency rules those out, Thumbor and Imageflow give you a self-hosted path.
The background PNG was the biggest offender. Correctly sizing it to the viewport and passing it through ImageOptim brought a bloated asset down to 100 kb. Similar treatment of several images greatly reduced total page weight.
Animated Content Needs Video
The GIF format is still better suited to simple graphics than motion. The login-sequence homepage intro was presented as a 7.3 mb GIF when a video approach offered a potential saving of 7 mb. Converting by way of FFmpeg to both mp4 and WebM loaded roughly 80% less content. While still large at ~1 mb, that remaining bulk is relatively significant on slower links. The Network Information API’s effectiveType property yields a simple string like slow-2g, 2g, 3g, 4g that you can check to conditionally substitute a JPEG for those on 2G or below.
This approach also applies to off-screen images. Carousels and long pages tend to fetch it all upfront, even when most aren’t immediately visible. Browser-native lazy loading isn’t yet available, and JavaScript has to fill the gap. The lazysizes library makes it straightforward, prefetching what the viewport will likely soon enter and offering an optional integration with IntersectionObserver for efficient visibility checks. Post-change, images were requested strictly when needed. Our team found resources like images.guide beneficial for delving into this.
Directing the Browser on Priorities
Browsers heuristically guess which bytes are urgent, and sometimes they get it wrong. Communicating importance directly is possible with several modern mechanisms: rel=preconnect, prefetch, and preload resource hints all let the page tell the browser what should come early.
Two audit results guided the effort:
- Avoid many round trips. Loading Google Fonts means at least two connections—one to
googleapis.comfor CSS, and one togstatic.comfor the font files. Adding preconnect cut roughly 300 ms from that setup. - Preload key requests. The
<link rel=preload>pattern informs the browser that a resource, like the two web fonts in use here, is part of current navigation. That shifts the font fetch up the waterfall, saving close to a second in text rendering. A caveat applies with hosted fonts—the URLs tend to rotate over time. Self-hosting is the reliable route for full control over a preload strategy.
A newer method, now in Chrome Canary, is priority hints. The importance attribute—taking values low, high, or auto—applies to non-critical styles, images, and API calls to quell contention, while permitting high priority for hero imagery. In this app, marking the early carousel slides with low priority and the foreground doodles at high cut graphics render time by two seconds on slow 3G.
Fonts Without Invisible Text
Typography that renders as invisible text provides the worst experience—you wait idle while font-face resolves. Browsers may wait up to three seconds before giving up and showing a system font on longer loads. Google’s Lighthouse suite flags this situation specifically. The font-display CSS descriptor restores a degree of control. The value swap delivers a zero-second block period and an infinite swap period. The page paints text promptly with a fallback font, switching to the font file when it has arrived. Applying it allowed early meaningful text in this case. The complementary font-display: swap was a load-order win, too, encouraging a shift from a block period to immediate fallback usage.
Reducing Render-Blocking Work
A critical rendering path analysis of the raw timeline reveal a few initial seconds where nothing was visible at all: a large, externally loaded stylesheet stalled the paint entirely. Extracting the styles responsible for the first paint and inlining them makes the essential content appear far sooner. The project used the npm package critical to accomplish this within the build step. But do not understate the complexity. Without planning an app-shell architecture from the start, retrofitting can spiral. In this case it worked with effort: first meaningful paint improved substantially.
Measuring the results
After applying the full set of optimizations, we measured the impact on a medium mobile device over a 3G connection. The Lighthouse performance score for the site jumped from 23 to 91.
That improvement didn't come from a single fix. It was the result of a process where every change we considered was checked against the Lighthouse report. If you want to see exactly how each improvement was implemented, the code and the associated pull requests are available in the oodle-demo repository.
Using data to predict performance needs
We believe machine learning will open up new possibilities for web performance. One idea we are keen to explore is using real user data to make better decisions about what to load next, rather than relying on guesswork.
Currently, we make many assumptions about which resources a user might need, and we use those assumptions to decide what to prefetch, preload, or pre-cache. This works well when we guess correctly, but it's hard to scale that approach across an entire site.
We already have access to the data required to make smarter choices. The Google Analytics reporting API can show us the most likely next pages and exit rates for any given URL. That gives us a solid basis for deciding which resources get priority.
By pairing this information with a probability model, we can avoid wasting a user's data on aggressive prefetching. Machine learning models such as Markov chains or neural networks are a natural fit for this kind of problem.
To encourage experimentation in this area, we are introducing Guess.js, an open-source project focused on data-driven user experiences for the web.
Our hope is that Guess.js will inspire others to explore how user data can improve performance and inform broader UX decisions. The project is available on GitHub today, built in collaboration with Minko Gechev, Kyle Matthews from Gatsby, Katie Hempenius, and other members of the open source community.
Final thoughts
Performance scores and metrics are useful guides, but they are not the end goal. They are tools that help us deliver faster, more reliable experiences.
We have all dealt with slow page loads on mobile. With the right approach, we can shift from simply measuring problems to building solutions that make the web feel instant.
Performance work is an ongoing process. The results we saw came from a series of incremental changes with continuous feedback from Lighthouse. That combination of the right tools and a disciplined process can lead to significant improvements and a better experience for every user.
Special thanks to Ward Peeters, Minko Gechev, Kyle Mathews, Katie Hempenius, Dom Farolino, Yoav Weiss, Susie Lu, Yusuke Utsunomiya, Tom Ankers, and the Lighthouse & Google Doodles teams.



