Optimizing Unity WebGL Performance

Updated June 2026
Unity WebGL builds run inside a browser sandbox with no direct GPU access, single-threaded execution, and a fixed memory heap. Achieving smooth performance requires targeted optimization across rendering, memory, asset loading, and garbage collection. This guide covers practical profiling workflows and the specific optimizations that have the largest impact on WebGL frame rate and load times.

Performance optimization should always start with measurement. Guessing at bottlenecks wastes time on optimizations that do not move the needle. The browser imposes unique constraints that make some desktop-oriented optimizations irrelevant and elevate others to critical importance. WebGL runs on a single thread, so CPU work that native builds spread across cores must fit within a single frame's budget on the web.

Step 1: Profile Your Build in the Browser

Two profiling tools are essential for WebGL optimization: Unity's built-in Profiler and the browser's developer tools. Use them together for a complete picture.

Unity's Profiler connects to WebGL builds through the "Development Build" and "Autoconnect Profiler" options in Build Settings. With these enabled, the Profiler shows CPU time breakdown by subsystem (rendering, scripts, physics, animation, garbage collection), GPU frame time, memory usage by category, and per-frame allocation tracking. This gives you the Unity-specific view of where frame time is spent.

Chrome DevTools' Performance tab provides the browser-side view. Record a few seconds of gameplay, then examine the flame chart to see JavaScript execution, WebAssembly compilation events, garbage collection pauses, and rendering pipeline stages. The "Bottom-Up" tab sorts functions by total time, quickly revealing which functions consume the most CPU. Firefox's profiler provides similar functionality with occasionally better WebAssembly stack trace resolution.

For GPU-specific analysis, Chrome's WebGL Inspector extension (or the built-in webgl-developer-tools flag) shows draw calls, state changes, and shader compilation events per frame. This is invaluable for identifying redundant draw calls, unnecessary state switches, and shader programs that take too long to compile at load time.

Establish a performance baseline before making changes. Record frame times, memory usage, and load times for your current build. After each optimization, measure again to verify the improvement. Some optimizations interact with each other, and an individual change that looks beneficial might actually make a different bottleneck worse.

Step 2: Optimize the Rendering Pipeline

Rendering is usually the largest consumer of frame time in WebGL builds. The Universal Render Pipeline (URP) is the correct choice for WebGL. Do not use HDRP, which is too heavy for browser rendering, or the Built-in Render Pipeline, which lacks the optimization features URP provides.

Draw call reduction is the single most impactful rendering optimization. Each draw call requires a JavaScript-to-WebGL API transition, which is slower than the equivalent native OpenGL call due to the browser's validation layer. Target under 200 draw calls per frame for desktop browsers and under 100 for mobile. Enable static batching for non-moving geometry, dynamic batching for small moving meshes (under 300 vertices), and GPU instancing for repeated objects like trees, props, and particle systems.

Sprite atlasing is the 2D equivalent of geometry batching. Combine sprites that appear together into atlases so they share a single texture and draw call. Unity's Sprite Atlas asset handles this automatically, but verify that your atlases are not too large (2048x2048 is a safe maximum for mobile compatibility) and that sprites from different scenes are in separate atlases to avoid loading unused textures.

Lighting has a direct impact on frame time. Bake lighting wherever possible using Unity's Lightmap system. Baked light contributes zero runtime cost because the illumination is stored in lightmap textures. Reserve real-time lights for dynamic objects that must cast moving shadows, and limit real-time lights to 1-2 per scene. Real-time shadow resolution should be 512 or 1024 for WebGL, not the 2048 or 4096 values that desktop builds can afford.

Shader complexity matters for WebGL. The WebGL 2.0 API (based on OpenGL ES 3.0) does not support compute shaders, geometry shaders, or tessellation. Complex fragment shaders with many texture samples, branching, and per-pixel calculations slow down rendering proportionally. URP's "Simple Lit" shader handles most use cases efficiently. For custom shaders, minimize texture reads, avoid branching, and test on lower-end hardware to ensure acceptable fill rate performance.

Step 3: Manage Memory and Heap Allocation

WebAssembly runs inside a fixed memory heap that Unity requests at startup. The "Initial Memory Size" in Player Settings determines the starting allocation. If the game exceeds this allocation, the browser must grow the heap, which can cause a visible pause as memory pages are allocated and old data is relocated.

Set the initial memory size based on profiling data from your development build. Monitor peak memory usage during gameplay and set the initial size to approximately 80% of peak, allowing growth for dynamic content without triggering frequent reallocations. For a typical 2D game, 32-64 MB is reasonable. For 3D games with textured environments, 128-256 MB is common. Avoid setting it higher than needed, as the browser reserves the full allocation at page load, reducing available memory for other page content.

Texture memory dominates most games' memory footprint. Use texture compression (ASTC on mobile, DXT on desktop) to reduce GPU memory by 4-8x compared to uncompressed RGBA. Generate mipmaps only for textures viewed at varying distances; UI textures and fixed-size sprites do not benefit from mipmaps and waste memory with them. Set the "Max Size" import setting on each texture to the smallest value that maintains acceptable visual quality.

Audio memory is often overlooked. Uncompressed audio clips loaded into memory can use significant space. For music, use streaming playback (Load Type: "Streaming") so the clip is decoded from compressed data in real time. For sound effects, use "Decompress On Load" with ADPCM compression for short clips and "Compressed In Memory" with Vorbis for longer effects. This approach keeps audio memory under 10-20 MB even for games with substantial sound design.

Step 4: Reduce Build Size with Code Stripping

Build size directly affects load time, which is the single most important metric for web game player retention. Players who wait more than 5-10 seconds for a game to load frequently abandon the page before gameplay begins.

Managed Stripping Level in Player Settings controls how aggressively Unity removes unused .NET library code from the build. Setting this to "High" can remove several megabytes of WASM code. The stripper analyzes code references and removes types and methods that are never called. However, code accessed through reflection (e.g., Type.GetType("ClassName")) is invisible to static analysis and may be incorrectly stripped. Preserve reflection targets using a link.xml file in the Assets folder that lists types and assemblies the stripper should not touch.

Engine code stripping removes unused Unity subsystems from the build. If your game does not use physics, the entire PhysX library can be stripped. If it does not use audio, the FMOD integration is removed. Enable "Strip Engine Code" in Player Settings and verify that no runtime errors appear from stripped subsystems. The savings vary but can reach 2-5 MB of compressed build size for projects that use a narrow set of engine features.

IL2CPP code generation settings also affect build size. Setting "IL2CPP Code Generation" to "Faster (smaller) builds" produces smaller WASM files at the cost of slightly slower runtime performance. For most web games where load time matters more than peak CPU throughput, this is the correct trade-off.

After stripping, apply Brotli compression to the build output. Brotli compresses WebAssembly effectively, often achieving 70-80% compression ratios. A 50 MB uncompressed build can compress to 12-15 MB with Brotli. Verify that your web server or CDN serves the compressed files with the correct Content-Encoding: br header.

Step 5: Optimize Asset Loading and Streaming

The default Unity WebGL build packs all assets into a single .data file that must download completely before gameplay begins. For large games, this means players wait for tens of megabytes before seeing any content. Addressable Assets solve this by allowing on-demand loading of asset groups.

Structure your Addressable groups around gameplay progression. The first group contains everything needed for the initial screen: the main menu UI, background music, and any intro content. This group should be as small as possible, ideally under 3-5 MB compressed. Subsequent groups load as the player navigates to different parts of the game. A level-based game might have one group per level, loading each when the player reaches it.

Pre-loading the next group while the player is engaged in the current level masks loading times entirely. Start the download for Level 2's assets when the player reaches 75% completion of Level 1. Use a subtle loading indicator (progress bar in the UI corner) rather than a full loading screen, so gameplay continues uninterrupted.

For the loading screen itself, show progress as a percentage of downloaded bytes and provide engaging content: game tips, control instructions, or preview images. Players are more tolerant of loading when they receive feedback and have something to read. An indefinite spinner with no progress indication feels much slower than a progress bar, even at the same actual load time.

Step 6: Handle Garbage Collection and Frame Spikes

Garbage collection (GC) in WebGL builds causes visible frame spikes because the single-threaded environment cannot collect garbage concurrently with gameplay. When the GC runs, it pauses the game entirely until collection completes, producing a stutter that ranges from a few milliseconds to over 100 milliseconds depending on the heap size and fragmentation.

The primary mitigation is reducing allocations during gameplay. Avoid creating new objects, strings, or arrays in Update(), FixedUpdate(), or LateUpdate(). Pre-allocate arrays and lists during initialization and reuse them. Use object pools for frequently created and destroyed objects like bullets, particles, and UI elements. Cache component references in Awake() instead of calling GetComponent() repeatedly.

String operations are a common hidden allocation source. String concatenation with the + operator creates a new string object each time. For debug logging, use conditional compilation (#if UNITY_EDITOR) to strip log calls from WebGL builds entirely. For runtime string assembly (UI text updates, score displays), use StringBuilder or pre-formatted string caches to minimize allocations.

LINQ operations (Where, Select, OrderBy) allocate enumerator objects and delegate closures on each call. In performance-critical code paths, replace LINQ with explicit loops. A foreach over a List is allocation-free in modern Unity, but foreach over other IEnumerable implementations may allocate.

If GC pauses remain noticeable despite reducing allocations, consider triggering collection at controlled moments, such as during screen transitions or pause menus, using System.GC.Collect(). This forces collection at a time when a brief pause is invisible to the player, rather than letting it happen during active gameplay at an unpredictable time.

Key Takeaway

Always profile before optimizing. The biggest WebGL performance wins come from reducing draw calls, compressing textures, stripping unused code, and eliminating per-frame allocations. Target these areas first, measure the improvement, and iterate until your frame budget is met.