Quick answer: ResourcePreloader is deprecated in Godot 4. Use preload() in script for static references, or ResourceLoader.load_threaded_request for background loading with progress. preload() is parse-time; load() is runtime and can hitch.

Here is how to fix Godot ResourcePreloader not preloading. You stuff 50 enemy textures into a ResourcePreloader node expecting zero-hitch loads. In game, the first time each enemy spawns there is a visible stutter. The preloader looks populated but assets are loaded on-demand. Godot’s resource loading has several mechanisms and the ResourcePreloader node is not the best choice in Godot 4.

The Symptom

Resources placed in a ResourcePreloader still cause frame hitches on first access. Large textures briefly freeze the game. Sound clips load on first play. Expected “preloaded” state does not prevent disk reads.

What Causes This

ResourcePreloader is deprecated. In Godot 4, the old ResourcePreloader node is kept for compatibility but not recommended. Use preload() or ResourceLoader APIs instead.

preload() vs load(). preload("path") is a parse-time keyword — the compiler embeds the resource reference in the script. The resource loads when the script’s scene is loaded. load("path") is runtime and hits disk when called. Confusing the two causes unexpected hitches.

Large resources still load on access. Even preloaded resources may defer actual GPU upload (textures) until first bind. The first frame that references the texture causes a hitch regardless of CPU-side preload state.

Threaded loading not used. For runtime-variable loads (different levels, different enemies per encounter), threaded loading is the proper solution. Without it, all load() calls block the main thread.

The Fix

Step 1: Use preload() for static references. For scenes or resources your script always uses:

extends Node

const ENEMY_SCENE = preload("res://enemies/goblin.tscn")
const HIT_SOUND = preload("res://sfx/hit.ogg")

func spawn_enemy():
    var enemy = ENEMY_SCENE.instantiate()
    add_child(enemy)

preload is a compile-time reference — no disk read at runtime. The resource loads when the script is parsed (at scene load). Subsequent access is free.

Step 2: Use ResourceLoader.load_threaded_request for async. For resources loaded dynamically (level data, player-customized assets):

extends Node

func load_level_async(path: String):
    ResourceLoader.load_threaded_request(path)

    while true:
        var status = ResourceLoader.load_threaded_get_status(path)
        match status:
            ResourceLoader.THREAD_LOAD_IN_PROGRESS:
                # Update progress bar
                var progress = []
                ResourceLoader.load_threaded_get_status(path, progress)
                if progress.size() > 0:
                    $ProgressBar.value = progress[0] * 100
                await get_tree().process_frame
            ResourceLoader.THREAD_LOAD_LOADED:
                var scene = ResourceLoader.load_threaded_get(path)
                return scene
            ResourceLoader.THREAD_LOAD_FAILED:
                push_error("Load failed: " + path)
                return null

Background thread handles disk reads. Main thread stays responsive. Update a progress bar via the progress array.

Step 3: Warm up GPU resources. Even CPU-loaded textures need GPU upload. Force upload by briefly rendering the texture:

func prewarm_texture(tex: Texture2D):
    # Trigger GPU upload by briefly rendering via a hidden TextureRect
    var rect = TextureRect.new()
    rect.texture = tex
    rect.modulate.a = 0
    add_child(rect)
    await RenderingServer.frame_post_draw
    rect.queue_free()

This ensures the texture is on the GPU before its first visible use, eliminating the first-use hitch.

Step 4: Migrate off ResourcePreloader. If you have a ResourcePreloader node, replace with a script-based solution using an array of preloaded constants:

extends Node

var _resources = {
    "goblin": preload("res://enemies/goblin.tscn"),
    "orc": preload("res://enemies/orc.tscn"),
    "troll": preload("res://enemies/troll.tscn"),
}

func get_enemy(type: String) -> PackedScene:
    return _resources.get(type)

Same pattern as ResourcePreloader but with parse-time loading and type safety.

“preload for what you always need. load_threaded_request for what you sometimes need. Never load() synchronously for anything big.”

Related Issues

For tween issues, see Tween Chain Stopping Midway. For signal-related async issues, Await Signal Never Completing.

preload for statics, load_threaded_request for dynamics. Never synchronous load at gameplay speed.