DEV Community

unity source code
unity source code

Posted on

The Unity Performance Bugs That Sneak Past QA and Show Up in Production

Most Unity performance and stability problems that surface after launch aren't exotic bugs — they're a handful of well-known footguns that every team steps on at least once. This post walks through the ones that show up most often in real projects, why they're easy to miss during development, and how to catch them before your players do.


If you've ever shipped a Unity game and then watched your crash-free rate quietly slide a few points below where it was in testing, you already know the feeling this post is about. Everything worked on your dev machine. Everything worked in the editor. Then real devices, real networks, and real players got involved, and suddenly you're staring at a crash log that only reproduces on a three-year-old mid-range Android phone with 2GB of RAM.

None of this is bad luck. It's a pattern. The same handful of mistakes show up across an enormous number of Unity projects, mostly because they're invisible during normal development — your dev machine has more RAM than your target device, your test network is fast and stable, and you're testing with a handful of save states, not the thousands of edge cases real players will generate. This post is a field-tested list of the ones worth checking before you ship, not after.

1. Memory Leaks From Event Subscriptions

This is, in my experience, the single most common source of slow memory creep in Unity projects. You subscribe to a static event or a UnityEvent in OnEnable, and you forget — or someone on the team forgets, six months later, in a different file — to unsubscribe in OnDisable or OnDestroy.

void OnEnable()
{
    GameEvents.OnScoreChanged += HandleScoreChanged;
}

// If this is missing, the object never gets garbage collected
void OnDisable()
{
    GameEvents.OnScoreChanged -= HandleScoreChanged;
}
Enter fullscreen mode Exit fullscreen mode

Individually, one missed unsubscribe is a rounding error. But in a game with dozens of UI panels being instantiated and destroyed repeatedly — level-complete screens, popups, shop panels — a leaked subscription on each of them adds up to a slow, steady memory climb that's genuinely hard to notice in a five-minute test session but very noticeable after a thirty-minute play session on a low-RAM device. This is exactly the kind of bug that sails through QA (short sessions, powerful test devices) and shows up in production as vague "the game gets laggy after a while" reviews.

The fix isn't clever, it's just disciplined: every += needs a matching -=, and if you're using a lot of static events, consider a lightweight event bus with automatic cleanup, or at minimum a code review checklist item specifically for this.

2. Draw Call and Batching Blindness

It's easy to build an entire prototype without ever opening the Frame Debugger, and then discover three weeks before launch that your game is pushing 400+ draw calls on mobile because every UI element and sprite is using a slightly different material instance.

A few checks that catch this early:

  • Same sprite atlas, same material for anything that can share one — most UI icons and common gameplay sprites should be batchable by default, not by accident.
  • Static batching for anything that doesn't move, and GPU instancing for repeated objects that do (particle-heavy effects, repeated enemies, tiled backgrounds).
  • Canvas splitting. A single giant UI Canvas where one animating element forces the entire canvas to rebuild every frame is a classic mobile performance killer that's invisible until you profile it. Split frequently-updating UI (timers, score counters, health bars) into their own Canvas, separate from static UI.

None of this requires deep rendering expertise — it requires actually opening the Frame Debugger and the Profiler once before launch instead of assuming things are fine because the editor feels smooth.

3. Physics Update Rate vs. Frame Rate Mismatches

If your game uses physics for core gameplay — and a lot of arcade and casual mechanics do — it's worth understanding that FixedUpdate runs on its own timestep, independent of your rendering frame rate. On lower-end devices where frame rate dips, physics can start to feel inconsistent or "floaty" in ways that are hard to diagnose if you're only looking at visual frame rate.

Two habits help here: keep gameplay-critical physics logic in FixedUpdate, not Update, so behavior stays consistent regardless of rendering performance; and actually test on your lowest supported target device, not just a flagship phone, since physics feel is one of those things that degrades quietly rather than breaking obviously.

4. Save Data That Breaks on Schema Changes

This one doesn't show up in your first release — it shows up in your first update, when you add a new feature that changes your save data structure and suddenly a chunk of your existing player base loses progress or crashes on load.

The fix is to treat your save format as a versioned contract from day one, even if version 1 only has one version:

[Serializable]
public class SaveData
{
    public int schemaVersion = 1;
    public int coins;
    public List<string> unlockedItems;
    // new fields get added with sensible defaults,
    // never inserted in a way that shifts existing indices
}
Enter fullscreen mode Exit fullscreen mode

Write a small migration step that checks schemaVersion on load and upgrades old saves rather than assuming every save file matches your current structure. It's a small amount of extra code that prevents what is otherwise one of the most reputation-damaging bug categories — players losing progress after an update almost always shows up as a wave of one-star reviews, and it's very hard to win that trust back.

5. Ignoring Low-End Devices Until It's Too Late

It's tempting to develop and test primarily on whatever device is sitting on your desk, which is usually newer and more powerful than what a meaningful chunk of your actual audience is using. The gap between "runs fine on my phone" and "runs fine on the median device in our target market" is often bigger than teams expect, especially for mobile titles targeting broad, price-sensitive markets rather than a narrow flagship-device audience.

A practical habit: keep at least one genuinely low-end device in your test rotation from the start of the project, not just before launch. Performance problems caught in week three are a quick fix. Performance problems caught in the week before submission are a crisis, and they tend to produce rushed, fragile fixes instead of good ones.

6. Platform-Specific Submission Requirements Treated as an Afterthought

A lot of the bugs above are things you can catch with good habits and testing discipline. But some of the most frustrating last-minute fire drills aren't gameplay bugs at all — they're platform requirements that got treated as a checkbox instead of a real part of the development process. Privacy disclosures that don't match what your SDKs actually collect, tracking permission prompts that aren't implemented correctly, minimum OS version requirements that quietly shifted since you last checked — these are all things that can stall a release by days or weeks, entirely disconnected from whether your game itself is any good.

I wrote up a more detailed, field-notes style account of exactly this experience — what actually goes wrong during a real iOS submission cycle in 2026, not just the theoretical checklist — over here: Shipping a Unity Game to the iOS App Store in 2026: A Developer's Field Notes. If you're heading into your first (or fifth) submission cycle, it's worth reading before you hit submit rather than after something bounces back.

7. Ad SDK Timing Bugs That Only Show Up in Production

Ad mediation is one of those integrations that works perfectly in a dev build with a test ad unit and then behaves completely differently once real ad fill enters the picture. A few timing bugs come up over and over:

  • Requesting a rewarded ad right when it's needed, instead of pre-loading. If you call LoadRewardedAd() the moment the player taps "watch ad for coins," you're now showing a loading spinner for however long that network call takes — sometimes instant, sometimes several seconds, depending on network conditions and fill rate. Pre-load the next ad as soon as the current one finishes, so it's ready before the player asks for it.
  • Not handling ad load failure gracefully. Test builds against a test ad unit almost always succeed, which means "no fill" and "network timeout" paths often go completely untested until real users hit them in production, sometimes leaving a player staring at a button that silently does nothing.
  • Double-triggering reward callbacks. Some mediation SDKs will call your reward callback more than once under certain network retry conditions if you're not careful about idempotency, which can turn into players getting duplicated currency — a bug that's rare, hard to reproduce, and expensive if it's ever exploited at scale.

Building a small internal wrapper around your ad SDK — one that handles pre-loading, failure states, and idempotent reward granting in one place — pays for itself the first time you have to debug one of these in production instead of three separate call sites.

8. Treating the Profiler as a Pre-Launch Task Instead of a Weekly Habit

A lot of the problems above share a common root cause: they're only caught by profiling, and profiling is one of those tasks that's easy to keep deferring because nothing forces you to do it. "I'll profile before launch" quietly becomes "I profiled once, two weeks before launch, found six things, and fixed the three I had time for."

The teams that ship the most stable builds tend to treat a profiling pass as a recurring habit — a quick check every week or two, on a real low-end device, looking specifically at memory trend over a play session, draw call count, and frame time spikes. Catching a memory leak the week it was introduced means looking at one or two recently-changed scripts. Catching it two months later means auditing the entire event subscription surface of the whole project. The math on when to profile is not close.

Building the Habit, Not Just the Checklist

The pattern across every item on this list is the same: these are all things that are cheap to catch early and expensive to catch late. A missing unsubscribe is a two-minute fix in week two and a multi-day memory-profiling session in week twenty. A save schema without versioning is free to add on day one and painful to retrofit after your first update ships to real players.

The actual fix isn't a longer checklist — it's building these habits into your normal development rhythm so they stop being a pre-launch scramble and become just how you write Unity code. Profile early and often, not just before a deadline. Test on your worst supported device, not your best one. Version your save data before you need to. Treat platform submission requirements as part of the build, not a separate afterthought tacked onto the end.

Where to Go Next

If any of this resonated and you're earlier in a project — trying to decide what to build rather than debugging what you've already built — it might be worth browsing what's already out there as a starting point rather than starting from a completely blank project. There's a full catalog of tested, source-available Unity templates over at Unity Source Code, spanning a range of genres, which can be a useful reference point for seeing how a production-ready project is actually structured before you commit months to building your own from scratch.

Whatever stage you're at — architecture, mid-development, or staring down a submission deadline — the underlying lesson is the same one experienced teams learn the hard way and try to pass on to everyone else: the boring, disciplined habits are the ones that actually save you time. The exciting shortcuts rarely do.


What's the Unity bug that's bitten you the hardest in production? Drop it in the comments — I'm always curious how consistent these patterns are across different teams and genres.

Top comments (0)