Quick answer: Every + concatenation, string.Format, and boxed integer-to-string conversion allocates. Cache static strings, use StringBuilder with Clear() across frames, write TextMeshPro values with the SetText format overload, and watch GC.Alloc in the Profiler.

Here is how to fix Unity GC spikes from string allocations. Your game runs smoothly most of the time, but every few seconds there is a visible hitch — maybe 20 ms, maybe 80 ms on mobile — and it shows up in the Profiler as a GC.Collect spike. You drill in and find your score HUD, your debug overlay, or your inventory tooltip allocates a hundred bytes per frame through innocent-looking string code. Nothing ever shipped on “looks innocent.” The fix is not hard but it takes discipline to do consistently.

The Symptom

In the Profiler, the CPU Usage module shows periodic spikes labeled “GC.Collect” or “GC.Alloc.” Gameplay runs fine between spikes but hitches briefly when the GC triggers. Sorting the hierarchy by GC.Alloc (right-click column headers to enable) reveals specific methods allocating KB per frame. Common offenders: UI.Text.text setters, debug logging, score displays, tooltip updates, inventory UI refreshes.

On mobile, even small per-frame allocations accumulate faster because the managed heap starts smaller. A 500-byte-per-frame allocation pattern that runs fine on desktop can cause a visible hitch every second on mid-tier Android.

What Causes This

String concatenation with +. string result = "Score: " + score; allocates a new string every time. The compiler converts this to string.Concat which internally allocates. If score is an int, there is also an implicit int.ToString() call that allocates the number’s string form.

string.Format and string interpolation. $"Score: {score}" compiles to string.Format, which allocates a params object[] to pass the arguments. Value types like int get boxed into object, adding more allocations. A single $"Health: {hp}/{maxHp}" can allocate 100+ bytes per call.

ToString() on value types. int.ToString(), float.ToString(), and enum ToString() all allocate. Enum ToString() is particularly expensive because it uses reflection internally — a single call can allocate hundreds of bytes.

Concatenating in a Text setter. scoreText.text = "Score: " + currentScore; allocates when you build the string, and triggers a UI rebuild when the text property changes. If the score only changes occasionally, this is fine; if you set the text every frame unconditionally, you re-allocate every frame.

Debug.Log in hot paths. Debug.Log($"Player at {transform.position}") in Update allocates the string, the Vector3 ToString, and internal logging infrastructure even if logging is disabled. Debug.Log is never truly free.

The Fix

Step 1: Cache static strings. Any constant string should be a const or static readonly field, not a literal in a hot path.

// Bad: allocates a new string every frame
scoreText.text = "Score: " + score;

// Better: only allocates when score changes
private int lastScore = -1;

void Update()
{
    if (score != lastScore)
    {
        scoreText.text = "Score: " + score;
        lastScore = score;
    }
}

The “only set when it changes” pattern eliminates most UI allocation. If the score updates once per enemy kill, that is once per minute of real allocations, not sixty times per second.

Step 2: Use StringBuilder for complex strings. For strings built from many parts, use a single long-lived StringBuilder with Clear() between uses.

using System.Text;

public class StatsHUD : MonoBehaviour
{
    private readonly StringBuilder sb = new StringBuilder(64);
    [SerializeField] private TMPro.TMP_Text display;

    void UpdateDisplay(int hp, int maxHp, int mp, int maxMp)
    {
        sb.Clear();
        sb.Append("HP: ").Append(hp).Append(" / ").Append(maxHp);
        sb.Append("\nMP: ").Append(mp).Append(" / ").Append(maxMp);

        // Pass StringBuilder directly to TMP — no string allocation
        display.SetText(sb);
    }
}

The key is TMP_Text.SetText(StringBuilder) — TextMeshPro reads the characters directly from the StringBuilder without materializing a string. If you use legacy UI Text, you have to call sb.ToString() which does allocate, but only when the value changes.

Step 3: Use SetText format overloads. TextMeshPro has SetText(string, float, float, float) overloads that format directly into the internal buffer with no allocation. Use them for common patterns.

// Zero allocations
display.SetText("Score: {0}", score);
display.SetText("HP {0}/{1}", currentHp, maxHp);

Step 4: Never enum ToString() in hot paths. Cache enum name strings in a static array or dictionary indexed by enum value. Enum ToString uses reflection and allocates enough to be visible in profiler.

static readonly string[] StateNames = {
    "Idle", "Walking", "Running", "Jumping"
};

// Use: StateNames[(int)currentState] instead of currentState.ToString()

Measuring Success

Open the Profiler, enable Deep Profile, and sort the CPU Usage hierarchy by GC.Alloc. Aim for zero bytes in steady-state gameplay frames. Any non-zero allocation in a frame where the game state did not change is a bug. One-time allocations (level load, menu open) are fine; per-frame allocations are not.

Unity’s Memory Profiler package can show allocations over time and let you compare snapshots. After applying the fixes above, take a snapshot, play for 30 seconds, take another snapshot, and diff them. The managed heap should grow by nearly nothing.

What About Logging?

Wrap expensive log statements in a [Conditional] method or an #if UNITY_EDITOR block so they are compiled out of release builds. For runtime diagnostics you actually want to ship, log to a ring buffer instead of Debug.Log and flush to file only when writing a bug report.

“Every string you build is a promise to garbage collect it later. Build fewer strings, GC less.”

Related Issues

For memory leaks that grow rather than spike, see Unity Memory Leak Texture Not Releasing. For profiler interpretation generally, Profiler Showing Editor Loop covers how to read the profiler correctly.

GC spikes are not a GC problem. They are a “you allocated too much” problem. Allocate less.