Managing Memory in Web Games
JavaScript uses automatic garbage collection, which means the runtime periodically scans the heap for objects that are no longer referenced and frees their memory. This is convenient for application development but dangerous for real-time games. A major GC pause can freeze your game loop for 5 to 30 milliseconds, causing visible stutter. On mobile browsers, where memory is shared between the system and all open tabs, exceeding the available memory causes the browser to kill your tab entirely, which players experience as a crash with no error message.
Memory management in web games has two separate goals. The first is minimizing garbage collection pauses by reducing the volume of temporary allocations during gameplay. The second is controlling total memory consumption to stay within the device's limits. Both require deliberate coding patterns and ongoing monitoring.
Profile Your Heap
Open Chrome DevTools and switch to the Memory panel. Take a heap snapshot before gameplay begins to establish your baseline memory consumption. Then start an allocation timeline recording and play your game for 30 to 60 seconds. Stop the recording and examine the results.
The allocation timeline shows a bar for every allocation that occurred during the recording period. Blue bars represent allocations that are still alive at the end of the recording. Gray bars represent allocations that were created and then garbage collected. A steady stream of gray bars during gameplay indicates per-frame allocations that are creating GC pressure. Click on individual allocations to see their type and the call stack that created them.
The allocation sampling profiler is a lower-overhead alternative that samples rather than recording every allocation. It produces a flame chart showing which functions are responsible for the most allocations. This is useful for longer profiling sessions where the full allocation timeline would slow down the game too much to be representative.
Record baseline numbers: total heap size at rest, peak heap size during gameplay, and the frequency and duration of GC pauses. These are your targets to improve.
Implement Object Pools
Object pooling is the most effective technique for eliminating GC pauses in game loops. The pattern is simple: at load time, pre-allocate a fixed number of objects for each frequently created type (bullets, particles, damage numbers, sound effect instances, UI notifications). Store them in an array or linked list. When you need a new object, grab one from the pool and reset its properties. When the object is no longer needed, return it to the pool instead of letting it be garbage collected.
A basic pool implementation needs three things: a backing array of pre-allocated objects, an index or pointer to the next available object, and acquire and release methods. The acquire method returns the next available object and marks it as active. The release method marks the object as inactive and returns it to the available set. Use a free list (a singly linked list threaded through the pool array) for O(1) acquire and release operations.
Size your pools based on the maximum number of simultaneous active objects you expect. A bullet pool for a shooter might have 200 slots. A particle pool for visual effects might have 2000. If the pool runs out, you have three options: ignore the request (a particle system can skip a particle without the player noticing), expand the pool (allocating during gameplay, but only as a rare fallback), or recycle the oldest active object (common for particle systems where the oldest particle is likely near the end of its life anyway).
Pool everything that is created and destroyed frequently: projectiles, enemies, collectibles, UI popups, audio source nodes, temporary collision results, and event objects. Even pooling JavaScript event objects that you create for custom event systems can eliminate a surprising amount of GC pressure.
Eliminate Per-Frame Allocations
Beyond object pooling, scrutinize your game loop for any code that creates new objects, arrays, or strings each frame. Common offenders include:
Vector and matrix math libraries that return new objects. An expression like let direction = playerPos.subtract(enemyPos).normalize() creates two new vector objects per call. Replace this with in-place operations: Vec3.subtract(direction, playerPos, enemyPos); Vec3.normalize(direction, direction); where direction is pre-allocated once and reused every frame.
Array methods like map, filter, reduce, slice, and concat all allocate new arrays. In hot code paths, replace them with for loops that write into pre-allocated arrays. A collision system that filters active entities every frame with entities.filter(e => e.active) creates a new array of references 60 times per second. Instead, maintain a separate active list that you update when entities activate or deactivate.
String concatenation for debug output or UI text creates new string objects. Cache formatted strings and only regenerate them when the underlying value changes. If your FPS counter updates every frame with "FPS: " + fps, that creates a new string 60 times per second. Update it once per second instead, or use a pre-allocated character buffer.
Closures capture variables from their enclosing scope, which can create hidden object allocations. Avoid creating closures inside the game loop. Define callback functions once at initialization and reference them by name rather than creating anonymous functions inline each frame.
Use TypedArrays for Game State
TypedArrays (Float32Array, Float64Array, Int32Array, Uint8Array, and others) allocate their storage as a contiguous block of memory outside the JavaScript GC heap. The GC does not scan or move this memory, which means storing your game state in TypedArrays eliminates GC pressure for that data entirely. TypedArrays also provide cache-friendly memory layouts because elements are packed contiguously in memory rather than scattered across the heap as separate JavaScript objects.
Entity-component-system (ECS) architectures benefit enormously from TypedArrays. Instead of storing each entity as a JavaScript object with properties for position, velocity, health, and so on, store each component type in a separate TypedArray. All X positions go in one Float32Array, all Y positions in another, all health values in an Int32Array. Processing all entities in a system like movement becomes a tight loop over contiguous arrays, which is both GC-free and CPU cache-friendly.
For WebGL buffer data, TypedArrays are required. Vertex positions, normals, UVs, and indices must be in TypedArrays before they can be uploaded to the GPU. If you are already using TypedArrays for WebGL data, extend the pattern to your game logic data as well. The same Float32Array that holds your entity positions can be used for both game logic and rendering.
Manage Asset Lifecycle
Textures, meshes, and audio buffers consume GPU memory (VRAM) and system memory. Unlike JavaScript objects, GPU resources are not automatically garbage collected. You must explicitly dispose of them when they are no longer needed, or they will remain in VRAM until the page is closed.
When the player moves to a new level, dispose of textures, meshes, and materials from the previous level. In Three.js, call .dispose() on textures, geometries, and materials. In Babylon.js, call .dispose() on meshes and textures. Track references to GPU resources and ensure every resource has a clear owner responsible for disposal.
Implement a reference counting system if multiple objects share the same texture or mesh. Increment the count when a new object references the resource, decrement it when an object is removed, and dispose the resource when the count reaches zero. This prevents disposing a texture that is still in use by another object while also preventing leaks from unreferenced resources.
Audio buffers decoded with the Web Audio API also consume significant memory. A five-minute music track decoded to raw PCM at 44.1kHz stereo consumes about 100 MB of memory. Release decoded audio buffers when transitioning between scenes and re-decode them on demand if the player returns.
Set Memory Budgets
Define maximum memory budgets for each asset category based on your lowest-spec target device. A reasonable starting point for a mobile-friendly web game is 128 MB total: 64 MB for textures, 16 MB for meshes, 16 MB for audio, and 32 MB for runtime JavaScript heap. Desktop games can double or triple these numbers, but having explicit budgets prevents unbounded growth.
Track memory usage against these budgets in your performance HUD. Display texture memory, mesh memory, audio memory, and JavaScript heap size as both absolute numbers and percentages of their budgets. When any category exceeds its budget, log a warning and investigate which assets are responsible.
Use the performance.measureUserAgentSpecificMemory() API (available in Chrome with cross-origin isolation headers) to get accurate JavaScript memory measurements. For GPU memory, engines like Three.js track texture and geometry byte counts internally. Sum these to approximate your total GPU memory usage.
Test on Memory-Constrained Devices
Mobile devices, budget laptops, and Chromebooks have significantly less memory than development machines. A game that uses 500 MB on your desktop runs fine because you have 16 GB of RAM. On a phone with 3 GB shared between the OS, other apps, and the browser, that same 500 MB triggers a tab kill.
Test on actual low-memory devices. If you do not have physical hardware, use Chrome DevTools to simulate memory pressure: open the Performance Monitor panel and watch the JS heap size during extended play sessions. Memory leaks that grow slowly (a few KB per frame) are invisible during short tests but consume hundreds of MB over a 30-minute session.
Play your game for an extended period (30 minutes or more) and compare the heap size at the end to the heap size at the start. Any growth indicates a memory leak. Take heap snapshots at the start and end, then use the "Comparison" view to identify objects that were allocated during gameplay but never freed. Common leaks include event listeners that are never removed, DOM nodes that are detached but still referenced, and GPU resources that are replaced but not disposed.
Aim for zero allocations per frame during gameplay. Pool frequently created objects, use in-place math, store state in TypedArrays, and explicitly dispose GPU resources when they are no longer needed. Test on low-memory devices to catch leaks before your players do.