Phaser performance optimization: measure first, then fix
The Glitchless team · October 27, 2026 · Phaser 3.60 and later, and Phaser 4
Most slow Phaser games are slow for one of a handful of reasons: sprites from separate images that can't share a draw call, objects created and destroyed in bursts, Text redrawn every frame, garbage made every frame, physics doing more than it needs to, big tilemaps, particles with no limit, or a canvas with more pixels than a phone can fill. Each one shows up in numbers you can read in a few minutes, so measure before you change anything.
The short answer
- Too many draw calls: pack sprites that share the screen into a texture atlas, so they share batches
- Hitches when many objects appear: pool bullets, enemies and effects in a Group with
maxSize,get()andkillAndHide() - Text that changes every frame: use
BitmapTextfor scores, timers and anything else that changes often - Hitches every few seconds: stop creating objects, arrays and strings in
update() - Physics doing more work than it needs to: use Arcade unless you need Matter, make walls static, and use
overlapwhere nothing needs pushing apart - Big tilemaps: keep camera culling on, use fewer layers, and in Phaser 4 draw static layers with
TilemapGPULayer - Particles without a limit: cap emitters with
maxAliveParticles, and reuse one emitter withexplode() - Fine on desktop, slow on phones: draw a fixed game size and let the Scale Manager stretch it; cut full-screen layers
First, measure: real frame times, not FPS
this.game.loop.actualFps is an average that Phaser updates once a second, so a half-second freeze barely moves it. The delta your scene's update(time, delta) gets, and this.game.loop.delta, are smoothed too by default: each is the average of the last 10 frames, and a frame longer than 200 ms (from the default fps.min of 5) is replaced by an earlier one. That hides exactly the hitches you're looking for. Use this.game.loop.rawDelta, the real time since the last frame, and report the typical frame, the 95th percentile and the worst frame.
Then read the shape. Slow everywhere and worst on phones points to draw calls or pixels. Spikes when things appear point to objects or text being made. Small hitches at a steady rhythm point to garbage collection. A game that gets slower the longer you play points to objects or particles piling up.
To see where a long frame went, record a few seconds in Chrome's Performance panel and look at which functions the long frames spent their time in; tick Memory to see the JavaScript heap as well. To count draw calls, capture one frame with Spector.js, a browser extension that lists the WebGL calls the frame made.
frame-log.js
// frame-log.js: call frameLog(this) from your scene's update().
// Every 5 seconds it prints the typical, 95th percentile and worst frame.
const frames = [];
let total = 0;
export function frameLog(scene) {
const loop = scene.game.loop;
const ms = loop.rawDelta; // the real time since the last frame; loop.delta is smoothed
frames.push(ms);
total += ms;
if (total < 5000) return;
frames.sort((a, b) => a - b);
const at = (q) => frames[Math.min(frames.length - 1, Math.floor(frames.length * q))].toFixed(1);
console.log(`frame ms: typical ${at(0.5)}, 95th ${at(0.95)}, worst ${at(1)} | ` +
`fps ${loop.actualFps.toFixed(0)}, display list ${scene.children.length}`);
frames.length = 0;
total = 0;
}
Too many draw calls
What you see. A Spector.js capture shows hundreds of draw calls a frame, and the game is slow on every device, worst on phones.
Why. Phaser draws Images, Sprites and BitmapText in batches, one draw call per batch. A batch ends when it needs more textures than the GPU has texture units (most have 8 or 16), when the shader changes (lights, filters or FX, a Shader object), or when it's full. On iOS and Android, Phaser uses one texture per batch by default (the mobile pipeline in Phaser 3.60, autoMobileTextures in Phaser 4), so every switch from one image to another starts a new draw call: enemy, coin, enemy, coin is four.
Pack the images that appear together into a texture atlas: one image with many frames and a JSON file that says where each frame is. TexturePacker exports Phaser's format. Load it with this.load.atlas() and pick frames by name, so the player, enemies, coins and bullets come from one texture and batch on every device. Leave a pixel of padding around each frame, and keep separate atlases for things that never share the screen, such as menus and each level's art.
Keep lights, filters, FX and Shader objects to a few, since each one ends the batch. Phaser 4's own rendering guide says not to use more than a few hundred draw calls a frame.
atlas.js
// preload(): one atlas image holds the player, enemies, coins and bullets.
this.load.atlas('sprites', 'assets/sprites.png', 'assets/sprites.json');
// create(): each object draws a frame of the same texture, so they share batches.
this.player = this.add.sprite(100, 300, 'sprites', 'player-idle');
this.add.image(400, 300, 'sprites', 'coin');
this.add.image(460, 300, 'sprites', 'enemy');
Hitches when many objects appear
What you see. A hitch when a wave spawns or a weapon fires fast, and a display list that keeps growing the longer you play.
Why. Every object you create is a new JavaScript object added to the scene's display list, and a Sprite also joins the update list so its animation runs. Every object you destroy is garbage. Objects you no longer need but never removed keep costing every frame: Phaser runs preUpdate on every active object on the update list and renders every visible one.
Make what a level needs once and reuse it. A Group with maxSize is a pool: get(x, y) hands back the first inactive member, or makes a new one while the group is under maxSize, and returns null when it's full. killAndHide() puts a member back by making it inactive and invisible, so it's skipped by both the update list and the renderer.
A reused member comes back as you left it, inactive and hidden, so turn it back on with setActive(true).setVisible(true), or for an Arcade Physics body with enableBody(true, x, y, true, true). With physics, put members away with disableBody(true, true), which also takes the body out of collisions; killAndHide() on its own leaves the body in the world.
In Phaser 4, a background of thousands of sprites that rarely change can be one SpriteGPULayer, drawn in a single draw call. Changing its contents rewrites its whole buffer, so fill it once.
bullets.js
class Level extends Phaser.Scene {
create() {
// A pool of at most 60 bullets, made as they're needed, then reused.
this.bullets = this.physics.add.group({ defaultKey: 'sprites', defaultFrame: 'bullet', maxSize: 60 });
}
fire(x, y) {
const bullet = this.bullets.get(x, y);
if (!bullet) return; // all 60 are in flight
bullet.enableBody(true, x, y, true, true); // a reused bullet comes back inactive and hidden
bullet.setVelocityY(-600);
}
update() {
// Put away bullets that left the screen instead of destroying them.
const bullets = this.bullets.getChildren();
for (let i = 0; i < bullets.length; i++) {
if (bullets[i].active && bullets[i].y < -16) bullets[i].disableBody(true, true);
}
}
}
Text that changes every frame
What you see. Frame time goes up while a timer or score is counting, and more with each label that changes.
Why. A Text object draws its string onto a canvas of its own with the browser's text drawing, and in WebGL uploads that canvas to the GPU as a new texture, every time the string or its style changes. Phaser's docs call this expensive if done often. setText with the same string does nothing, but a timer showing tenths of a second changes ten times a second.
Use BitmapText for numbers and labels that change. Its letters are frames of a font texture, so it batches like a sprite and changing it involves no canvas drawing or upload. Make the font with a tool such as BMFont or Snow BMF and load it with this.load.bitmapFont().
Keep Text for words that rarely change, and call setText only when the value does.
score.js
// preload(): a bitmap font made with a tool such as BMFont or Snow BMF (a PNG and its XML).
this.load.bitmapFont('digits', 'fonts/digits.png', 'fonts/digits.xml');
// create()
this.scoreText = this.add.bitmapText(16, 16, 'digits', '0', 32);
// When the score changes, not every frame:
this.scoreText.setText(String(this.score));
Hitches every few seconds
What you see. Small hitches at a steady rhythm, and in Chrome's Performance panel with Memory ticked, a JavaScript heap that keeps rising and dropping.
Why. Everything made in update() is garbage a frame later: a new Phaser.Math.Vector2(), an array from filter or map, a closure, a string built for the HUD. The browser cleans it up in garbage collections, and while it collects, your game's code is paused.
Create vectors, arrays and temporary objects once, outside update(), and reuse them. Loop over group.getChildren() with a plain for loop instead of chaining filter and map in code that runs every frame, and build HUD strings only when the value changes.
chase.js
// Made once, reused every frame.
const toPlayer = new Phaser.Math.Vector2();
function chase(enemy, player, speed) {
toPlayer.set(player.x - enemy.x, player.y - enemy.y).normalize().scale(speed);
enemy.body.setVelocity(toPlayer.x, toPlayer.y);
}
Physics doing more work than it needs to
What you see. Frame time grows with the number of moving bodies, and the Performance panel shows the time going to the physics step.
Why. Every physics step moves each dynamic body and runs every collider and overlap you added. Arcade Physics handles only rectangles and circles, and Phaser's docs call it very fast. Matter is a full rigid-body engine, with polygons, constraints and joints, for games that need them.
Use Arcade unless the game needs what only Matter does. Put walls and platforms that never move in a static group (this.physics.add.staticGroup()): static bodies sit in a tree that isn't rebuilt every frame. Use overlap instead of collider for pickups and triggers, which don't need pushing apart, and disable the bodies of pooled objects you've put away.
Arcade keeps moving bodies in a tree too, rebuilt every frame. Phaser's docs say that past about 5,000 moving bodies the rebuild costs more than it saves, so set useTree: false. In Matter, turn on enableSleeping, so bodies at rest act like static ones until something wakes them.
physics.js
// Game config: Arcade, unless you need what only Matter does.
const config = {
physics: {
default: 'arcade',
arcade: { useTree: true }, // false once you have more than about 5,000 moving bodies
},
// ...
};
// create(): walls and platforms that never move are static bodies.
const walls = this.physics.add.staticGroup();
walls.create(400, 568, 'sprites', 'ground');
this.physics.add.collider(this.player, walls);
// Pickups only need detecting, not pushing apart.
this.physics.add.overlap(this.player, this.coins, this.collect, null, this);
Big tilemaps
What you see. Frame time grows with how much of the map is on screen: worse zoomed out, on big screens and with every extra layer.
Why. A TilemapLayer only draws the tiles inside the camera's view, plus a tile of padding, but it works out each of those tiles on the CPU every frame. Zooming out, a large canvas and several stacked layers all multiply the tiles.
Leave culling on: don't call setSkipCull(true) on big layers. Merge decorative layers in your map editor so fewer layers are drawn, and limit how far the camera zooms out.
In Phaser 4, pass true as the fifth argument of createLayer() to get a TilemapGPULayer. It keeps the layer's tiles in a texture and draws the whole layer as one quad with a shader, so its cost depends on the pixels it covers rather than the number of tiles, and Phaser's docs recommend it for large numbers of tiles on phones. It works with orthographic maps, one tileset image and WebGL only, and after you change tiles you have to regenerate its data, so use it for layers that don't change.
tilemap.js
// create()
const map = this.make.tilemap({ key: 'level1' });
const tiles = map.addTilesetImage('terrain', 'terrain');
// Phaser 3 and 4: culled to the camera by default. Don't call setSkipCull(true) on big layers.
const ground = map.createLayer('Ground', tiles, 0, 0);
// Phaser 4: a layer that doesn't change, drawn as one quad by a shader
// (orthographic maps, one tileset image, WebGL only).
const background = map.createLayer('Background', tiles, 0, 0, true);
Particles without a limit
What you see. Frame time climbs during explosions, rain or trails, and keeps climbing while an effect runs.
Why. An emitter's maxParticles and maxAliveParticles both default to 0, which means no limit, so it makes as many particles as its quantity, frequency and lifespan ask for. Every live particle is updated and drawn each frame, and an emitter left running where nobody can see it costs the same.
Set maxAliveParticles on every emitter, keep lifespan short, and call stop() on emitters that are off-screen or finished. For bursts such as hits and explosions, create one emitter with emitting: false and call explode(count, x, y) each time, rather than making an emitter per explosion. Dead particles go back into the emitter's pool and are reused.
sparks.js
// create(): one capped emitter for every spark burst.
this.sparks = this.add.particles(0, 0, 'sprites', {
frame: 'spark',
lifespan: 400,
speed: { min: 80, max: 200 },
maxAliveParticles: 200,
emitting: false,
});
// On a hit:
this.sparks.explode(12, x, y);
Fine on desktop, slow on phones
What you see. Smooth on your computer, slow on phones and big high-resolution screens, and faster when the game is smaller.
Why. Phaser draws its canvas at the game's width and height, and the Scale Manager stretches it to fit with CSS. Size the game from window.innerWidth * window.devicePixelRatio and a phone with a pixel ratio of 3 draws nine times the pixels of the same screen at a ratio of 1. Phaser 4's docs name filling pixels as the one place the GPU is likely to be the bottleneck, and full-screen backgrounds, transparent overlays, filters and FX each fill the screen again.
Pick a fixed game size for your art and let Phaser.Scale.FIT stretch it to the screen. Don't multiply the size by devicePixelRatio. Cut layers that cover the whole screen, and keep filters and FX for moments that need them, especially on phones.
config.js
const config = {
type: Phaser.AUTO,
width: 1280, // the pixels Phaser draws: not window.innerWidth * window.devicePixelRatio
height: 720,
scale: {
mode: Phaser.Scale.FIT, // stretch the canvas with CSS to fit the screen
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [Boot, Level1],
};
Check the fix
Run the same scene the same way before and after a change: the same level, the same route, about the same length of time. Compare the typical, 95th percentile and worst frame from the logger above, not the FPS counter, and the draw calls in a Spector.js capture.
Test on the slowest phone you support, in its own browser, not only in your desktop browser's device mode, which shrinks the screen but keeps your computer's GPU.
What your players' devices do
Your laptop isn't your players' phones. A scene that's smooth for you can drop frames on a mid-range Android phone, and nothing crashes, so nobody tells you.
Glitchless is an open-source SDK for Phaser 3.60 and later and Phaser 4 (and Three.js, Babylon.js and PlayCanvas). instrumentPhaser(glitchless, { game, Phaser }) times every frame of Phaser's loop in every player's session, split into update and render time, counts draw calls and shader compiles on the WebGL context, and records game objects, particles, physics bodies, tweens and event listeners alongside. Each hitch gets its likely cause: a burst of new objects, a texture made mid-game, a shader compiling, a scene change, your script or the renderer. It all splits by GPU tier, browser and version.
Your coding agent reads the same data over MCP, and with the game open in a browser it can list each scene's display list by type and texture, then measure the scene again to check its own fix. The same SDK records deaths and positions for death heatmaps.
Ask your coding agent
Use Glitchless to find why my Phaser game is slow for players: show the levels with the worst 95th percentile frame time with their draw calls, game object counts and hitch causes, fix the top problem, and measure the scene again to show the difference.
Frequently asked questions
Why is my Phaser game slow only on phones?
Usually pixels and textures. Phone GPUs fill pixels more slowly, and on iOS and Android Phaser batches one texture at a time by default, so sprites from separate images each cost a draw call. Draw a fixed game size, stretch it with the Scale Manager, and put sprites that share the screen in one atlas.
How many draw calls is too many?
Phaser 4's rendering guide says not to use more than a few hundred a frame, and phones want fewer. If a Spector.js capture shows a draw call for nearly every sprite, textures are breaking the batches.
Should I cap the frame rate?
fps: { limit: 30 } in the game config makes Phaser run its step at most 30 times a second. That cuts the work done each second, but it doesn't make a slow frame any faster, so a hitch is still a hitch. Fix what's slow first.
Should I use Arcade or Matter physics?
Arcade, unless the game needs what only Matter has: shapes other than rectangles and circles, constraints and joints, or rigid-body dynamics. They're two separate systems, and Phaser's docs call Arcade very fast.
Sources
- Phaser source: TimeStep (actualFps, delta smoothing, rawDelta)
- Phaser 4 Rendering Concepts (batching, texture units, TilemapGPULayer, SpriteGPULayer)
- Phaser 4 Shader Guide (what ends a batch)
- Phaser 3.60 source: the mobile pipeline
- Phaser docs: Textures and atlases
- Phaser docs: Group
- Phaser docs: Text
- Phaser docs: Arcade Physics
- Phaser docs: Matter Physics
- Phaser source: TilemapLayer and TilemapGPULayer
- Phaser source: ParticleEmitter
- Phaser docs: Scale Manager
- Chrome DevTools: Fix memory problems
- Spector.js
Something wrong or out of date? Email support@glitchless.gg and we'll fix it. Glitchless isn't affiliated with Phaser Studio Inc.; product names are their owners' trademarks.