A game that runs at 60 FPS on your development PC and a flagship phone can still stutter on the phones most players actually own. On Android especially, a large share of your audience is on budget devices with weak GPUs, little memory and aggressive thermal throttling.
This is the checklist we run on our own Unity games before they go anywhere near a store. It is ordered roughly by impact — start at the top.
1. Profile on a real low-end device first
Never optimise by guessing. Make a Development Build, install it on the cheapest phone you can find, and connect the Unity Profiler. The first question is always: is the CPU or the GPU the bottleneck? The fixes are completely different.
- CPU-bound: scripts, physics, animation, UI rebuilds, draw-call submission.
- GPU-bound: too many pixels, overdraw, heavy shaders, post-processing.
At 60 FPS you have ~16.6 ms per frame. On mobile, aim for 11–13 ms so the phone has headroom to stay cool.
2. Actually ask for 60 FPS
On Android and iOS, Unity defaults to 30 FPS unless you say otherwise, and vSyncCount is ignored on mobile. Set the target once at startup:
void Awake()
{
Application.targetFrameRate = 60;
}
Also enable Optimized Frame Pacing in Player Settings → Android → Resolution and Presentation. It evens out frame delivery, so 60 FPS feels smooth instead of juddery.
3. Tune URP for mobile
If you are on the Universal Render Pipeline, create a dedicated low-end URP asset and assign it to your lowest quality level:
- HDR off and MSAA off or 2x on low-end devices.
- Render Scale 0.8–0.9 — a small drop in resolution is almost invisible on a phone screen and saves a lot of GPU time.
- Depth Texture and Opaque Texture off unless an effect needs them.
- Shadow distance as short as you can get away with, one cascade, and soft shadows off.
- Keep post-processing to the essentials. Bloom and colour grading are usually affordable; depth of field and motion blur rarely are.
4. Cut draw calls and SetPass calls
Every change of mesh or material costs CPU time. Open the Frame Debugger and count.
- Keep the SRP Batcher enabled (it is on by default in URP) and use SRP-compatible shaders.
- Mark non-moving scenery as Static so static batching can combine it.
- Use GPU instancing for many copies of the same mesh — trees, rocks, coins.
- Share materials. Ten objects with ten slightly different materials cannot be batched together.
- For 2D, pack sprites into Sprite Atlases so one draw call can render many sprites.
5. Compress textures properly
Textures are usually the biggest chunk of memory — and memory pressure is what crashes low-end phones.
- Use ASTC compression. Choose 6x6 or 8x8 blocks for most textures and 4x4 only where detail matters, such as UI.
- Set a sensible Max Size per texture. A 4K texture on a prop that covers 50 pixels on screen is pure waste.
- Enable mipmaps for 3D textures (they improve both speed and quality at a distance), and disable them for UI.
- Turn off Read/Write unless a script needs the pixels — otherwise the texture is kept in memory twice.
6. Fight overdraw
Low-end GPUs have limited fill rate: the number of pixels they can shade per second. Every transparent layer drawn on top of another costs you again.
- Avoid large, full-screen transparent images and stacks of UI panels.
- Keep particle systems small on screen and low in count — big, soft smoke particles are an overdraw trap.
- Use URP’s Rendering Debugger overdraw view to see the hot spots.
7. Keep shaders simple
Prefer Simple Lit or Unlit shaders on mobile and save the full Lit shader for hero objects. In custom shaders, use half precision where you can, avoid expensive per-pixel maths, and strip unused shader variants to cut build size and load times.
8. Bake your lighting
Real-time lights and shadows are expensive on phones. Bake lightmaps for static scenery, use light probes to light moving objects cheaply, and keep real-time lights to a minimum — ideally one directional light.
9. Stop allocating garbage every frame
Garbage-collection spikes cause the classic “hitch every few seconds”. Open the Profiler’s GC Alloc column and hunt anything that allocates every frame:
- String concatenation and formatting in
Update(especially UI text updated every frame). - LINQ queries and lambdas that capture variables in hot code.
GetComponentorFindcalls every frame — cache references inAwake.- Creating new arrays or lists each frame — reuse them instead.
Keep Incremental GC enabled in Player Settings to spread the remaining collection work across frames.
10. Pool what you spawn
Instantiating and destroying bullets, enemies or effects every second causes both CPU spikes and garbage. Unity ships a built-in pool (we walk through the object pool pattern in our design patterns guide):
using UnityEngine;
using UnityEngine.Pool;
public class BulletSpawner : MonoBehaviour
{
[SerializeField] Bullet prefab;
ObjectPool<Bullet> pool;
void Awake()
{
pool = new ObjectPool<Bullet>(
createFunc: () => Instantiate(prefab),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
defaultCapacity: 32);
}
public Bullet Spawn() => pool.Get();
public void Despawn(Bullet b) => pool.Release(b);
}
11. Keep UI and physics lean
UI: when any element on a Canvas changes, the whole Canvas may need rebuilding. Split UI into separate Canvases for static and frequently changing elements, disable Raycast Target on graphics that are not clickable, and avoid layout groups on elements that update every frame.
Physics: prefer primitive colliders (box, sphere, capsule) over mesh colliders, trim the Layer Collision Matrix so layers that never interact are not tested, and check that your Fixed Timestep is not running physics more often than you need.
12. Ship the right build settings
- IL2CPP scripting backend with ARM64 — faster code, and 64-bit is required by Google Play anyway.
- Compress audio: Vorbis for effects, Streaming load type for long music tracks, and Force To Mono where stereo adds nothing.
- Consider the Adaptive Performance package to react to thermal state on supported devices.
- Finally, play for 15 minutes, not 15 seconds. Many phones throttle once they heat up — the frame rate that matters is the one after the device gets warm.
The quick checklist
| Area | Check |
|---|---|
| Profiling | Measured on a real low-end device, CPU vs GPU known |
| Frame rate | targetFrameRate = 60, Optimized Frame Pacing on |
| Rendering | Low-end URP asset, render scale < 1, HDR off |
| Draw calls | SRP Batcher, static batching, instancing, atlases |
| Textures | ASTC, sensible max sizes, Read/Write off |
| GPU | Overdraw checked, simple shaders, baked lighting |
| CPU | Zero per-frame GC allocations, pooling in place |
| UI & physics | Canvases split, collision matrix trimmed |
| Build | IL2CPP + ARM64, audio compressed, thermal-tested |
Performance is a feature, and it is much cheaper when it is planned from the prototype onward than bolted on before launch. It also has a real effect on budget — see our breakdown of how much a mobile game costs to make.
If your Unity game is struggling on real devices, that is exactly the kind of work our LiveOps & porting team does. Send us a build and we’ll tell you where the milliseconds are going.