A Practical Guide to Optimizing a Next.js WebGL Game

A Next.js game can look small while still having an expensive initial load. The browser may need to hydrate React, initialize WebGL, parse a rendering engine, decode texture atlases, load a tutorial, and request leaderboard data before the player makes a single move. That was the problem with Color Tiles, a browser game built with Next.js, React, and PixiJS. During optimization, its mobile Lighthouse score improved from a verifiable 59 to 97, while LCP fell from 11.36 seconds to 2.29 seconds. Th
A Next.js game can look small while still having an expensive initial load.
The browser may need to hydrate React, initialize WebGL, parse a rendering engine, decode texture atlases, load a tutorial, and request leaderboard data before the player makes a single move.
That was the problem with Color Tiles, a browser game built with Next.js, React, and PixiJS. During optimization, its mobile Lighthouse score improved from a verifiable 59 to 97, while LCP fell from 11.36 seconds to 2.29 seconds.
The score is not the main point of this article. The useful part is the process: decide what the player needs for the first playable screen, then move everything else out of that loading path.
For a more detailed, metric-by-metric account of the optimization work, read the full Color Tiles performance case study on Medium.
1. Measure the production loading path
Do not optimize a Next.js development server. Development mode includes compilation, source maps, React checks, and other work that does not represent the deployed application.
Start with a production build:
npm run build
npm start
Enter fullscreen mode Exit fullscreen mode
Then use Chrome DevTools and Lighthouse with one consistent mobile profile:
- Run Lighthouse and record LCP, Total Blocking Time, CLS, and transfer size.
- Open the Network panel, disable the cache, reload, and sort requests by size.
- Record a Performance trace and inspect the LCP marker and long tasks.
- Use the Coverage panel to find large JavaScript files with substantial unused code.
Classify every initial request into two groups:
- Needed now: the page shell, game controls, a stable board placeholder, and code required for the first interaction.
- Needed later: tutorials, leaderboards, below-the-fold content, result dialogs, and optional compatibility code.
This classification is more useful than trying to improve the overall Lighthouse score directly.
2. Put the game runtime behind a loading boundary
Keep the route and useful page content server-rendered, but isolate browser-only game code in a Client Component. Canvas and WebGL code should not be part of the server-rendering path.
If the game has a Start or Play action, load the runtime when the player uses it:
"use client";
import { useState, type ComponentType } from "react";
type GameComponent = ComponentType<{ initialMode: string }>;
export function GameLoader({ initialMode }: { initialMode: string }) {
const [Game, setGame] = useState<GameComponent | null>(null);
const [loading, setLoading] = useState(false);
async function loadGame() {
if (loading) return;
setLoading(true);
try {
const mod = await import("./color-tiles-game");
setGame(() => mod.ColorTilesGame);
} finally {
setLoading(false);
}
}
if (Game) return <Game initialMode={initialMode} />;
return (
<div className="game-shell">
<button onClick={loadGame} disabled={loading}>
{loading ? "Loading..." : "Play"}
</button>
</div>
);
}
Enter fullscreen mode Exit fullscreen mode
This creates a separate JavaScript chunk and keeps PixiJS, level generation, input handling, and game state out of the initial route bundle.
Reserve the final game dimensions in the loader so the page does not jump when the chunk arrives:
.game-shell {
width: min(100%, 760px);
aspect-ratio: 4 / 3;
}
Enter fullscreen mode Exit fullscreen mode
If the game must start automatically, use next/dynamic with ssr: false instead. The important parts are the same: isolate the browser-only module and render a placeholder with stable dimensions.
3. Import only the PixiJS modules the game uses
The original implementation imported the complete legacy bundle:
import * as PIXI from "pixi.js-legacy";
Enter fullscreen mode Exit fullscreen mode
The game only needed the WebGL renderer and a small set of display primitives. Replace the package-wide import with module-level imports:
import { BaseTexture, Renderer, Texture } from "@pixi/core";
import { Container } from "@pixi/display";
import { Rectangle } from "@pixi/math";
import { Sprite } from "@pixi/sprite";
import { AnimatedSprite } from "@pixi/sprite-animated";
Enter fullscreen mode Exit fullscreen mode
Create the renderer directly:
const renderer = new Renderer({
view: canvas,
width: 1,
height: 1,
antialias: true,
backgroundAlpha: 0,
});
Enter fullscreen mode Exit fullscreen mode
After changing imports, build again and confirm that the old legacy bundle is no longer present in the game chunk. Also test the oldest browser and device class you support. Removing a Canvas fallback is only correct when WebGL is part of your browser support baseline.
Reducing the bundle saves more than download bytes. Mobile devices also spend less time parsing, compiling, and executing JavaScript.
4. Do not mount tutorial media before it is visible
An animated tutorial is useful, but it should not compete with the board during initial loading.
First, convert animated GIFs to MP4 or WebM. Video usually compresses animation much more efficiently and gives the browser a better decoding path.
Second, do not render the video element until its modal is open or its section is close to the viewport:
const [visible, setVisible] = useState(false);
const frameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const frame = frameRef.current;
if (!frame) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
observer.disconnect();
setVisible(true);
},
{ rootMargin: "160px 0px" },
);
observer.observe(frame);
return () => observer.disconnect();
}, []);
return (
<div ref={frameRef} className="tutorial-frame">
{visible ? (
<video autoPlay loop muted playsInline preload="none">
<source src="/tutorial-demo.mp4" type="video/mp4" />
</video>
) : (
<div className="tutorial-placeholder" aria-hidden="true" />
)}
</div>
);
Enter fullscreen mode Exit fullscreen mode
Give the placeholder the same width and aspect ratio as the video. This keeps CLS stable while removing media download and decoding from the first screen.
5. Treat page images and game textures differently
Regular React content can use next/image, responsive sizes, lazy loading, and an image CDN:
<Image
src="/game-preview.webp"
alt="A Color Tiles game board"
fill
sizes="(max-width: 760px) 100vw, 600px"
quality={60}
loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode
The sizes value must reflect the rendered CSS width. Without it, a phone may receive an image intended for a desktop layout.
PixiJS textures and sprite atlases need a different path because the renderer loads their URLs directly. For those assets:
- Export WebP or AVIF variants where the target browsers and renderer support them.
- Remove transparent padding that is not used by atlas coordinates.
- Keep atlas dimensions and frame rectangles synchronized.
- Put URL generation behind one helper so the CDN hostname or format can change in one place.
const imageBaseUrl =
process.env.NEXT_PUBLIC_IMAGE_BASE_URL ??
"https://img.example.com";
export function imageAsset(path: string) {
return `${imageBaseUrl}/${path.replace(/^\/+/, "")}`;
}
Enter fullscreen mode Exit fullscreen mode
Use a tiny board placeholder during startup. Do not automatically preload the full-resolution background. Preload raises priority, so a large background can delay JavaScript, fonts, or smaller assets that are required sooner.
Only preload a resource after a Network trace shows that it is discovered too late and is genuinely required for the first playable frame.
6. Cache immutable assets with versioned URLs
For local static images, Next.js can send a long cache lifetime:
const nextConfig = {
images: {
minimumCacheTTL: 31536000,
},
async headers() {
return [
{
source: "/:path*\\.(png|jpg|jpeg|webp|avif|svg)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable",
},
],
},
];
},
};
Enter fullscreen mode Exit fullscreen mode
Apply equivalent cache rules at the CDN or object storage origin for assets served from another hostname. A header configured in Next.js does not control a separate CDN automatically.
immutable is safe only when an asset change also changes its URL. Use content hashes, versioned filenames such as tiles-atlas.v3.webp, or a version query controlled by the deployment. Never overwrite an immutable URL and expect returning players to receive the new file.
If a critical asset uses a separate hostname, establish the connection early:
<link rel="preconnect" href="https://img.example.com" />
Enter fullscreen mode Exit fullscreen mode
Use this for an origin needed near the top of the page, not for every third-party host.
7. Delay API work until the feature needs it
A leaderboard request should not run while the renderer is initializing unless leaderboard data is visible on the first screen.
Choose the trigger that matches the interface:
- Fetch after the player starts the game.
- Fetch when the leaderboard approaches the viewport.
- Fetch after a run ends, when the result screen needs the ranking.
For a below-the-fold panel, IntersectionObserver is usually enough:
const observer = new IntersectionObserver(
(entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
void refreshRankings();
},
{ rootMargin: "480px 0px" },
);
Enter fullscreen mode Exit fullscreen mode
Disconnect the observer after the first request, and guard the fetch function against duplicate calls. The feature remains available, but its network and React work no longer competes with the first playable frame.
8. Consider inline CSS only after the large work is gone
Once the JavaScript and media path is under control, a small game page may benefit from Next.js CSS inlining:
const nextConfig = {
experimental: {
inlineCss: true,
},
};
Enter fullscreen mode Exit fullscreen mode
This can remove a render-blocking stylesheet request and make the correctly sized game shell available with the HTML.
It is a finishing optimization, not a first step. It increases the HTML size, repeats styles between pages, and removes the benefit of a separately cached stylesheet. Because the option is experimental, inspect the production output again after every Next.js upgrade.
9. Verify performance and gameplay together
Every loading optimization changes timing, so a faster Lighthouse result is not enough. Run the production build and check that:
- The initial route does not download the game chunk before its intended trigger.
- Tutorial video and leaderboard requests do not start early.
- The game shell keeps stable dimensions while loading.
- Canvas pixels render instead of remaining blank or transparent.
- Mouse, keyboard, touch, undo, restart, and resize behavior still work.
- Texture URLs return
200and atlas frame mappings remain correct. - Mobile and desktop layouts do not overflow or overlap.
Automate the repeatable checks with Playwright, then use Lighthouse for controlled performance comparisons. Change one related group at a time so a regression can be traced to a specific decision.
A useful order of work
For a Next.js browser game, this order usually gives the clearest return:
- Separate the server-rendered page from the browser-only game runtime.
- Reduce the rendering-engine bundle.
- Delay tutorials, leaderboards, and below-the-fold content.
- Resize and compress page images and texture atlases.
- Add correct cache rules and connection hints.
- Test smaller finishing changes such as CSS inlining.
The core question is simple:
Must this resource be downloaded, parsed, decoded, or executed before the player can make the first move?
If the answer is no, give it a later trigger. That is how a visually small game becomes a genuinely small initial load without removing features from the product.


