Quick answer: VSync overrides targetFrameRate. Set QualitySettings.vSyncCount = 0 before Application.targetFrameRate = 60. On mobile, the OS clamps framerate to display refresh; you cannot exceed 60/90/120 Hz depending on device. Use -1 for “as fast as possible”.

Here is how to fix Unity Application.targetFrameRate being ignored in builds. You set it to 30 to save mobile battery; the player runs at 60. You set it to 144 for high-refresh monitors; the player caps at 60. The cause is the interaction with VSync — if VSync is on, your targetFrameRate request is silently overruled.

The Symptom

In editor, targetFrameRate works as expected. In build, the actual FPS does not match the value you set. Sometimes higher (uncapped), sometimes lower (capped to refresh rate), depending on platform and VSync settings.

What Causes This

VSync precedence. When QualitySettings.vSyncCount is greater than 0, Unity ignores targetFrameRate and presents at the display refresh rate divided by vSyncCount.

Platform-default behavior. Mobile defaults vSyncCount to 0; desktop usually defaults to 1. The same code produces different behavior across platforms.

Display refresh ceiling. targetFrameRate cannot exceed the display refresh rate when vsync is on, and may not exceed it even with vsync off depending on driver settings.

Wrong magic value. targetFrameRate = 0 means “use platform default”, not unlimited. -1 means “run as fast as possible”.

The Fix

Step 1: Disable VSync before setting targetFrameRate.

using UnityEngine;

public class FrameRateBootstrap : MonoBehaviour
{
    [SerializeField] private int targetFps = 60;

    void Awake()
    {
        QualitySettings.vSyncCount = 0;          // disable vsync
        Application.targetFrameRate = targetFps;
    }
}

Place this on a bootstrap object that runs first. The order matters: vSyncCount must change before targetFrameRate to take effect cleanly.

Step 2: Use vSync for tear-free if you do not need a custom cap.

QualitySettings.vSyncCount = 1;       // vsync at every refresh
Application.targetFrameRate = -1;     // ignored when vsync is on

vSync provides smooth output without configuration but you cannot pick custom rates.

Step 3: For mobile, set per-device.

int displayHz = (int)Screen.currentResolution.refreshRateRatio.value;
int chosenFps = Mathf.Min(60, displayHz);  // cap at 60 even on 120Hz

QualitySettings.vSyncCount = 0;
Application.targetFrameRate = chosenFps;

For battery savings, drop to 30 or 45 in low-power gameplay states. Bump back to 60 in combat.

Step 4: Verify the actual cap with a frame counter.

[SerializeField] private Text fpsLabel;

void Update()
{
    if (Time.frameCount % 10 == 0)
        fpsLabel.text = $"FPS: {1f / Time.unscaledDeltaTime:F0}";
}

Step 5: Confirm in build, not just editor. Editor frame rate is influenced by editor focus, scene rendering, and play mode pause. Always validate the cap in a real build on the target platform.

Per-Platform Notes

Android: Some manufacturers (Samsung, Xiaomi) have system-level FPS caps in battery saver. targetFrameRate cannot bypass those.

iOS: 120Hz support requires Info.plist CADisableMinimumFrameDurationOnPhone = true. Without it, iPhones cap to 60 even on 120Hz hardware.

Steam Deck: SteamOS exposes its own per-game FPS limit in the overlay. Players can override your in-game cap.

“VSync wins over targetFrameRate. Disable vsync first if you want a custom cap. -1 for unlimited, 0 for platform default, positive for explicit.”

Related Issues

For Unity vsync issues, see VSync Not Working in Build. For Pygame CPU usage, see Pygame Clock.tick CPU.

vSyncCount = 0. Then targetFrameRate. Verify in real build. The cap holds.