Dynamic Loading and Resource Management in Godot - load, preload, and Background Loading

Created: 2026-02-08Last updated: 2026-07-09

A practical guide to choosing between load() and preload() in Godot, asynchronous background loading with ResourceLoader, building a loading screen with a progress bar, and managing memory with WeakRef, explained with diagrams and code.

"The game freezes for a moment right when I switch scenes." "I want a loading screen when entering a big stage." As a game grows, how you load resources always becomes an issue. In Godot, simply choosing when and how to load a resource makes a big difference in how the game feels.

The idea is simple, and it comes in three tiers: load small things you need right away up front, load big things when you actually need them, and load huge things in the background behind a loading screen. This article covers choosing between preload() and load(), asynchronous loading with ResourceLoader, building a loading screen with a progress bar, and memory management with WeakRef.

Illustration of dynamic resource loading, with resources read from disk into the game and supplied when they're needed

What You'll Learn

  • The difference between preload() and load(), and when to use each
  • The three steps of asynchronous background loading with ResourceLoader
  • How to build a loading screen with a progress bar (change_scene_to_packed)
  • Memory management via reference counting and WeakRef

Sponsored

Choosing Between preload() and load()

Godot has two basic functions for loading resources. They differ in when they load and whether the path can be a variable, so pick based on the use case.

Diagram of the difference between preload and load: preload loads resources at constant paths up front when the script is parsed, while load reads resources at runtime using paths built from variables, only once they're needed

preload(): Load It Up Front

For small resources you use constantly, like bullets and sound effects, preload() is ideal. The resource is loaded when the script is parsed, so there's zero delay at the moment you call it at runtime.

const BULLET_SCENE := preload("res://scenes/bullet.tscn")   # Path must be a constant literal
const HIT_SOUND := preload("res://audio/hit.wav")

func shoot() -> void:
    add_child(BULLET_SCENE.instantiate())   # Usable immediately with no delay
  • Paths must be literal strings (variables aren't allowed)
  • Everything is loaded when the script is parsed
  • Best for small, frequently used assets

load(): Load It When You Need It

For resources that vary by condition, like the weapon the player picked or enemies that change with difficulty, use load(). Its biggest strength is that you can build the path from a variable.

func load_weapon(weapon_name: String) -> Node:
    var path := "res://weapons/%s.tscn" % weapon_name   # Build the path dynamically
    return load(path).instantiate()
  • Paths can be built dynamically
  • Reads from disk only the first time (after that the cache returns instantly)
  • Best for large or conditional resources

tips: Using a lot of preload() makes a scene's initial load slower. Large textures and 3D models have an outsized impact, so switch big assets to load() or the background loading covered next. It's total size, not count, that matters.

Asynchronous Loading with ResourceLoader

Calling load() on a big scene blocks the main thread until loading finishes, freezing the game. For heavy resources, like RPG dungeon transitions or open-world chunk loading, ResourceLoader's asynchronous API is the answer. It loads in the background while your game and its animations keep running.

Diagram of the three async loading steps: 1. load_threaded_request hands the load to another thread, 2. the main thread (the game) keeps running while loading proceeds in the background and load_threaded_get_status reports progress, 3. load_threaded_get retrieves the resource once complete

Asynchronous loading is three steps: request, monitor, retrieve.

# 1. Request the load on a separate thread
ResourceLoader.load_threaded_request("res://levels/stage_2.tscn")

func _process(_delta: float) -> void:
    var progress := []   # Empty array that receives progress (explained below)
    var status := ResourceLoader.load_threaded_get_status(
        "res://levels/stage_2.tscn", progress)

    match status:
        ResourceLoader.THREAD_LOAD_IN_PROGRESS:
            print("Loading: %d%%" % int(progress[0] * 100))   # progress[0] holds 0.0 to 1.0
        ResourceLoader.THREAD_LOAD_LOADED:
            # 3. Complete, so retrieve the resource
            var scene := ResourceLoader.load_threaded_get("res://levels/stage_2.tscn")
            _on_load_complete(scene)
        ResourceLoader.THREAD_LOAD_FAILED:
            printerr("Loading failed")
        ResourceLoader.THREAD_LOAD_INVALID_RESOURCE:
            printerr("Invalid path, or no request was made")

Here's what the three APIs and the statuses do.

Method / statusRole
load_threaded_request(path)Starts an asynchronous load
load_threaded_get_status(path, progress)Retrieves progress (0.0 to 1.0)
load_threaded_get(path)Retrieves the resource once complete
THREAD_LOAD_IN_PROGRESS / LOADED / FAILED / INVALID_RESOURCEThe four states

tips: Passing progress as an empty array is Godot's convention for "return a value by reference." GDScript functions can't return multiple values directly, so you pass an empty array and let the engine store progress in progress[0]. Also, setting the third argument use_sub_threads = true loads sub-resources like textures and meshes in parallel, making it faster still.

Sponsored

Hands-On: Building a Loading Screen with a Progress Bar

Once you know asynchronous loading, let's show it to the player with a loading screen. RPG dungeon transitions, stage changes in a level-based action game, area transitions in an open world: every genre has moments where you hold the player with a progress bar while a heavy scene loads.

Diagram of the loading screen flow: a CanvasLayer loading screen starts the async load, updates the progress bar from load_threaded_get_status (60%, for example), and switches to the target scene with change_scene_to_packed once complete

Build the loading screen with a CanvasLayer, monitor progress in _process, and update the bar. When it completes, switch scenes with change_scene_to_packed().

# loading_screen.gd (registered as an Autoload)
extends CanvasLayer

@onready var progress_bar: ProgressBar = $ProgressBar
@onready var label: Label = $Label

var _target_path := ""

func load_scene(scene_path: String) -> void:
    _target_path = scene_path
    show()
    var err := ResourceLoader.load_threaded_request(scene_path)   # Start loading
    if err != OK:
        printerr("Failed to start loading: %s" % scene_path)
        return
    set_process(true)

func _process(_delta: float) -> void:
    if _target_path.is_empty():
        return
    var progress := []
    match ResourceLoader.load_threaded_get_status(_target_path, progress):
        ResourceLoader.THREAD_LOAD_IN_PROGRESS:
            progress_bar.value = progress[0] * 100      # Match the bar to the progress
            label.text = "Loading... %d%%" % int(progress[0] * 100)
        ResourceLoader.THREAD_LOAD_LOADED:
            var scene := ResourceLoader.load_threaded_get(_target_path)
            get_tree().change_scene_to_packed(scene)    # Switch to the finished scene
            _target_path = ""
            set_process(false)
            hide()
        ResourceLoader.THREAD_LOAD_FAILED:
            label.text = "Loading failed"
            set_process(false)

Calling it takes one line.

LoadingScreen.load_scene("res://levels/stage_2.tscn")

There are two key points.

  • Register the loading screen as an Autoload: change_scene_to_packed() replaces the entire current scene tree. If the loading screen is part of that tree, it gets wiped out along with it, so keep it resident via Autoload so it survives scene transitions.
  • Progress comes from progress[0]: feed the 0.0 to 1.0 value that load_threaded_get_status() writes into the empty array straight into ProgressBar.value. That tells the player how much longer they have to wait.

Memory Management and WeakRef Caching

How you release resources matters as much as how you load them. In a large game, memory runs out if you never free what you're not using.

First, Godot caches resources by path when you load() them. Calling load() again with the same path returns the in-memory cache rather than reading from disk (the same instance).

Godot resources are reference counted. The engine counts how many variables reference a resource and frees it the moment that count hits zero (this is different from tracing GC in Java or C#). Holding a resource in a normal variable keeps that reference alive so it's never freed, but WeakRef doesn't increment the reference count.

Diagram of the difference between reference counting and WeakRef: holding a resource in a normal variable keeps the reference count at one or more so it stays in memory, while WeakRef doesn't increment the count so the resource is freed automatically once nothing else references it

That lets you build a soft cache: "I'd like to keep this, but I'm fine letting it go once nothing else uses it."

var _cache: Dictionary = {}

func get_resource(path: String) -> Resource:
    if _cache.has(path):
        var res: Resource = _cache[path].get_ref()   # Pull it from the weak reference
        if res:
            return res                                # Reuse it if it's still alive
    var loaded := load(path)
    _cache[path] = weakref(loaded)                    # Record it without incrementing the reference count
    return loaded

If get_ref() returns null, the resource has already been freed, so you reload it with load(). Frequently accessed resources are more efficient held in a normal variable; WeakRef suits resources you might use again but would rather trade for memory savings.

Here's the design guidance in summary.

Diagram of memory management best practices: drop unneeded references on stage transitions, limit preload to small assets shared by all scenes and use load for stage-specific ones, soft-cache reusable resources with WeakRef, and load large resources asynchronously
GuidelineWhat to do
Manage per stageDrop references to stage-specific resources on transition
Keep preload minimalOnly small assets used in every scene. Stage-specific ones use load()
Soft cache with WeakRefFor resources that might be reused but don't need to stay resident
Load large resources asynchronouslyUse load_threaded_request() for texture atlases and 3D models
Sponsored

Bonus: Good to Know for Later

  • Watch out for duplicate requests on the same path: calling load_threaded_request() twice with the same path raises an error. If multiple places might call it, check the state with load_threaded_get_status() first.
  • Use type hints for safety: passing a type name as the second argument, as in load_threaded_request(path, "PackedScene"), loads with a type check.
  • This is separate from loading save data: everything here concerns loading assets (scenes and images). Saving and restoring play state belongs to the save/load system, where ResourceLoader's async API is also useful.

Summary

  • preload() loads at parse time. Ideal for small, frequently used things (constant paths only)
  • load() loads at runtime. Use it for dynamic paths and conditional loading (disk I/O only the first time)
  • Load big scenes asynchronously through the three steps of load_threaded_request() so the main thread never stalls
  • Put the loading screen in an Autoload, update the bar from load_threaded_get_status() progress, and call change_scene_to_packed() on completion
  • Resources are managed by reference counting. WeakRef lets you build a soft cache that doesn't add references

Start by replacing a heavy stage transition with load_threaded_request() and a progress bar, and feel the freeze disappear. "Makes you wait, but never locks up" is the foundation of a game that feels good to play.

Further Reading