Shaving seconds off first load on slow networks
PROXX, a web-based Minesweeper clone from Chrome engineers, targets a much wider range of devices than your typical demo app. Back in 2019, the team made it a goal to support feature phones: devices with weak CPUs, little memory, small non-touch screens, and unreliable connections. These inexpensive devices are common in emerging markets, and optimizing for them forced the team to think hard about loading performance.
The starting point was not pretty. On an emulated 2G connection, the unoptimized app showed a blank white screen for over 8 seconds before anything rendered. On 3G, that was reduced to 4 seconds, but users still couldn’t do anything useful for several seconds after that. By the time the app was truly interactive, 11 seconds had elapsed on 2G and 6 seconds on 3G.
Reading the waterfall
Digging into the network waterfall revealed two main problems: too many new connections and a serial chain of JavaScript dependencies. Every new HTTP connection costs roughly 1 second on 3G and 2.5 seconds on 2G for connection setup. PROXX was opening separate connections for index.html, the font stylesheet from fonts.googleapis.com, a font file from fonts.gstatic.com, Google Analytics, and the web app manifest.
Some of these were easy to eliminate. The font stylesheet consisted of only two @font-face rules, so it made no sense to fetch it over a dedicated connection—it was inlined into the HTML. The font files themselves were moved to PROXX's own server, avoiding a second third-party connection.
Connections for analytics and the manifest didn't block rendering, though it was still better to load them during idle time, when they wouldn't compete for bandwidth with critical resources.
The second issue was that the JavaScript modules formed a chain: each file loaded only after the previous one finished. Since the module dependency graph is known at build time, those resources don't have to be discovered sequentially. Adding <link rel="preload"> tags made the browser fetch all dependencies as soon as the HTML arrived, instead of when the module loader finally asked for them.
Those two tweaks cut TTI from 11 seconds to 8.5 seconds on 2G, roughly the amount of time the removed connection set-ups would have taken.
Prerendering for a perceptual boost
Even at 8.5 seconds, the app still started with a long, blank screen. The problem was that index.html was just a shell—everything, including the markup, was created by JavaScript after download and execution. The team wanted to send styled markup with the initial response so the browser could paint something useful as soon as possible.
Since PROXX is built as a JAMStack app with no server, the team chose prerendering over server-side rendering. At build time, a Puppeteer instance—headless Chrome controlled via Node—loaded the app, ran the JavaScript, and serialized the resulting DOM back into index.html. That standalone HTML now contained the fully rendered game board, not just a script tag.
This changed the paint experience even though it didn't change the amount of JavaScript the browser had to load and execute. First Meaningful Paint moved from 8.5 seconds to 4.9 seconds on 2G. TTI stayed around 8.5 seconds because it's bounded by script evaluation, not network time. But the user now sees an actual game board, not a white void, while the real interactivity finishes loading.
Inlining critical paths
The waterfall analysis also highlighted something less obvious: the majority of each request’s time, for every resource, was spent waiting for the first byte of the response. The server was almost never the bottleneck—the network round trip was.
HTTP/2 Push was developed for exactly this problem, but it is widely considered too complex to get right and isn't recommended anymore. The pragmatic answer is to inline critical resources directly into the HTML, even if that means sending a bigger initial file and giving up cache reuse on those resources.
In PROXX, the critical CSS was already inlined thanks to the CSS Modules tooling used by the bundler. The harder part was inlining the JavaScript modules needed for first render and interactivity, including their dependencies. Each inline script adds both network and parse/execute cost, so this has to be done selectively for what's genuinely needed upfront.
Once the team had inlined those critical modules, TTI dropped by one more second. At the end of the process, the entire initial experience—the FMP and full interactivity—was delivered in a single index.html payload. The browser can start painting while the HTML is downloading, and once it's parsed and executed, the app is done loading. There is no second wave of resource fetches that leaves the user staring at a static shell.
Shrinking the Critical Path
If the initial index.html contains everything the app needs to become interactive, it likely contains a lot more than that. In our case, the file was around 43 KB, but the landing experience is just a settings form, a start button, and some persistence logic. That’s a heavy payload for a small amount of functionality.
To find out what was eating the budget, we used a source map explorer to break down the bundle. As suspected, it included the full game logic, the rendering engine, win/lose screens, and various utilities—none of which are needed at startup. Moving everything not strictly required for interactivity into lazily-loaded modules can cut TTI dramatically.
The fix is code splitting. Instead of one monolithic file, the bundler (Webpack, Rollup, Parcel) creates smaller chunks that are loaded on demand via dynamic import(). Statically imported modules stay in the initial bundle; dynamically imported ones are fetched only when their import() call runs. Network requests cost time, so the rule is: statically load what is critical for first paint and interaction, and defer the rest. But don’t delay modules that are certain to be used until the last second—Phil Walton’s Idle Until Urgent pattern is a good middle ground.
We created a lazy.js file that statically imports everything we don’t need upfront, then dynamically imported it from our main file. Some Preact components ended up in lazy.js, which is a complication because Preact doesn’t handle lazily-loaded components natively. We solved that with a small deferred wrapper component that renders a placeholder until the real component arrives.
export default function deferred(componentPromise) {
return class Deferred extends Component {
constructor(props) {
super(props);
this.state = {
LoadedComponent: undefined
};
componentPromise.then(component => {
this.setState({ LoadedComponent: component });
});
}
render({ loaded, loading }, { LoadedComponent }) {
if (LoadedComponent) {
return loaded(LoadedComponent);
}
return loading();
}
};
}
With the wrapper in place, render() can accept a Promise of a component. The <Nebula> background component, for instance, initially renders as an empty <div>, which is swapped out once the component finishes loading.
const NebulaDeferred = deferred(
import("/components/nebula").then(m => m.default)
);
return (
// ...
<NebulaDeferred
loading={() => <div />}
loaded={Nebula => <Nebula />}
/>
);
After splitting, index.html dropped to 20 KB—less than half the original size. On WebPageTest, FMP and TTI were just 100 ms apart, since only parsing and executing the inlined JavaScript remained. On a 2G connection, the app was fully interactive after 5.4 seconds, with non-critical modules loading in the background.
Handling the Load Time Gap
The rendering engine is not in the critical module list, yet the game can’t start without it. Rather than disabling the start button until it’s ready, we rely on users taking enough time to configure settings. If they’re faster than their connection, a loading screen waits for the remaining modules. In practice, the rendering engine usually finishes before the user hits “Start.”
Measuring and Optimizing
Optimization should always start with measurement, on real devices over 3G, or on WebPageTest when a device isn’t available. The filmstrip shows how loading feels, and the waterfall reveals which resources cause delays. Keep these techniques in mind:
- Send as many assets as possible over a single connection.
- Preload or inline resources needed for first render and interactivity.
- Prerender the app to improve perceived performance.
- Use aggressive code splitting to trim the JavaScript required for interactivity.



