Skip to main content

๐Ÿ“ฆ Lesson 7.3: Addressables โ€” Async Content Management

As a game grows, two problems arrive together: it uses too much memory because everything referenced by a scene loads with it, and it ships as one giant build you must fully re-download to change a single texture. Addressables is Unity's answer to both. It lets you refer to any asset by a string address, load and unload it asynchronously and on demand, and even host content remotely so you can update it without shipping a new build. This lesson covers the model, the core API, and the one rule you must never break: everything you load, you must release.

๐ŸŽฏ Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why Addressables beats direct scene references and the old Resources folder for memory and content control
  • Describe addresses, labels, groups, and the AssetReference serialized field
  • Load an asset with Addressables.LoadAssetAsync<T>(key) and await its AsyncOperationHandle<T>
  • Instantiate with Addressables.InstantiateAsync
  • Always release with Addressables.Release / ReleaseInstance โ€” and explain reference counting
  • Outline content catalogs, remote hosting, and DownloadDependenciesAsync

Estimated Time: 70 minutes  ยท  Prerequisite: Lesson 1.3 (async/await & awaitable patterns in Unity)

In This Lesson

Why Addressables

To see the problem Addressables solves, look at the two ways you already load content and where each fails at scale.

๐Ÿ”— Direct references

Drag a prefab into a public field and it's a hard reference. Simple โ€” but every asset a scene directly or indirectly references loads into memory when the scene loads, whether the player reaches it or not. A big level with hundreds of linked assets means a huge, unavoidable memory spike.

๐Ÿ“ Resources folder

Resources.Load("path") loads by string, on demand โ€” better. But everything in any Resources folder is packed into one bundle baked into the build, bloating build size and startup, and it can't be updated without a new build. Unity officially discourages it.

๐Ÿ“ฆ Addressables

Refer to assets by address, load and release them individually and asynchronously, bundle them however you like, and host them locally or remotely. Memory, build size, and content updates all come under your control.

Three concrete wins fall out of that. First, memory control: nothing loads until you ask, and it unloads the moment you release, so you hold only what's on screen. Second, remote content: assets can live on a CDN and download at runtime, so you can add or fix content without resubmitting the app. Third, decoupling: code refers to content by key, not by a hard link, so scenes stay lean and assets can move between bundles without touching a line of code.

๐Ÿ”Ž It builds on Lesson 1.3. Every Addressables load is asynchronous โ€” it returns immediately and completes later, because the asset may be on disk or across a network. You'll await these operations using exactly the async/await patterns from Lesson 1.3. If awaiting still feels shaky, revisit it before the code below.

Addresses, Labels & Groups

You mark an asset "Addressable" with a checkbox in its Inspector (or by dragging it into the Addressables Groups window). Doing so gives it three things:

๐Ÿ“– The vocabulary

  • Address โ€” a unique string key you load by, e.g. "Enemies/Goblin". Defaults to the asset path but you can rename it to anything stable.
  • Label โ€” a tag you can attach to many assets (e.g. "forest", "boss"). Load or preload everything with a label in one call.
  • Group โ€” how assets are packed into bundles and whether those bundles are local (in the build) or remote (on a server). Groups are a build-time packing decision, invisible to your loading code.

The Addressables Groups window is where you organize all this โ€” assets grouped into rows, each with its address and labels, and each group configured local or remote. It's a UI Toolkit window, so like the tools in the last two lessons it's reconstructed here as a diagram rather than a screenshot:

The Addressables Groups window, reconstructed A mock of Unity's Addressables Groups window. It is a table with columns for Group/Asset Address, Labels, and Bundle location. Two groups are shown. The first, "Local_Enemies (Local)", contains rows for addresses Enemies/Goblin and Enemies/Orc, each labelled "enemy". The second, "Remote_Levels (Remote)", contains rows for Levels/Forest and Levels/Cavern, each labelled "level" and "dlc", with the location marked as a remote server URL. ๐Ÿ“ฆ Addressables Groups Group / Address Labels Bundle โ–พ Local_Enemies Local Enemies/Goblin enemy in build Enemies/Orc enemy in build โ–พ Remote_Levels Remote Levels/Forest level dlc CDN url Levels/Cavern level dlc CDN url Code loads by address or label โ€” it never knows or cares which bundle, local or remote, holds the asset. Moving an asset between groups changes packing only; not a single line of loading code.
Figure 1: The Addressables Groups window (reconstructed). Assets are organized into groups that decide bundling and local-vs-remote hosting; each asset carries an address and labels. Your loading code targets addresses and labels, staying blind to which bundle โ€” or which continent โ€” actually holds the bytes.

Loading Asynchronously

Loading returns an AsyncOperationHandle<T> โ€” a handle to an in-progress operation that you await and later release. There are two things you'll load: an asset (data you keep, like a prefab or sprite) and an instance (a live GameObject in the scene). Start with loading an asset:

using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;         // Addressables
using UnityEngine.ResourceManagement.AsyncOperations; // AsyncOperationHandle

public class GoblinFactory : MonoBehaviour
{
    AsyncOperationHandle<GameObject> handle;   // keep it so we can release later

    async Task LoadPrefabAsync()
    {
        // Kick off the load. Returns immediately; the work happens over time.
        handle = Addressables.LoadAssetAsync<GameObject>("Enemies/Goblin");

        // Await completion โ€” non-blocking, like any Task (Lesson 1.3).
        GameObject prefab = await handle.Task;

        if (handle.Status == AsyncOperationStatus.Succeeded)
            Instantiate(prefab, transform.position, Quaternion.identity);
        else
            Debug.LogError("Failed to load Enemies/Goblin");
    }
}

The key you pass โ€” "Enemies/Goblin" โ€” is the address. You could equally pass a label to load a whole set, or an AssetReference (next section). If the asset is remote and not yet cached, this same call downloads it first; your code doesn't change. You can also subscribe instead of awaiting: handle.Completed += op => { ... }; โ€” useful in non-async contexts.

To put a live object in the scene, use InstantiateAsync, which loads (if needed) and instantiates in one step:

// Load-and-spawn in one call. The handle here represents the *instance*.
AsyncOperationHandle<GameObject> spawnHandle =
    Addressables.InstantiateAsync("Enemies/Goblin", position, Quaternion.identity);
GameObject goblin = await spawnHandle.Task;

๐Ÿ“– Definition โ€” AsyncOperationHandle<T>

A ref-counted handle to a loading operation. It exposes .Task (awaitable), .Status (Succeeded/Failed), .PercentComplete (for progress bars), and .Result (the loaded T once done). Crucially, it is also the token you release to free the asset. Hold onto it โ€” losing the handle means you can't release, and that's a leak.

The Golden Rule: Release

Addressables tracks memory with reference counting. Each load increments a count on the underlying asset and its dependencies; each release decrements it. When the count hits zero, Unity unloads the asset and its bundle. Load without releasing and the count never drops โ€” the asset stays in memory forever. This is the single most important discipline of the whole system.

void OnDestroy()
{
    // Release the ASSET load: use Addressables.Release with the handle.
    if (handle.IsValid())
        Addressables.Release(handle);
}

// For something spawned with InstantiateAsync, release the INSTANCE:
void Despawn(GameObject goblin)
{
    // ReleaseInstance destroys the GameObject *and* decrements the ref count.
    Addressables.ReleaseInstance(goblin);
}

โš ๏ธ Release the right way for the right load

  • Loaded with LoadAssetAsync โ†’ release with Addressables.Release(handle).
  • Spawned with InstantiateAsync โ†’ release with Addressables.ReleaseInstance(go) (this also destroys the object). Calling plain Destroy() on it leaks the ref count.
  • Every load needs exactly one matching release. Loading the same key twice increments the count twice โ€” release twice.

The lifecycle below is the mental model to carry: an operation moves from load, to an awaited handle, to use, to release, and only then does memory actually free. Skip the last box and you have a leak that grows the longer the session runs.

flowchart LR A["LoadAssetAsync(key)
ref count +1"] --> B["await handle.Task"] B --> C["use handle.Result
(prefab / sprite / data)"] C --> D["Addressables.Release(handle)
ref count โˆ’1"] D --> E{"count == 0?"} E -->|yes| F["asset + bundle unloaded
memory freed"] E -->|no| G["still held by
another loader"]

Figure 2: The load/release lifecycle. Every load bumps a reference count; every release drops it; the asset frees only when the count reaches zero. Forgetting the release step is the classic Addressables memory leak.

โœ… A pattern that's hard to leak

Wrap each load in a small owner object โ€” a component, a pool, a "handle bag" โ€” whose job is to release in OnDestroy or on unload. If who owns this handle always has a clear answer, releases rarely get lost. The mini-project's LevelLoader does exactly this: it keeps a list of the instances it spawned and releases every one on unload.

AssetReference Fields

Loading by a hand-typed string works, but a typo is a runtime failure and there's no drag-and-drop. AssetReference fixes both: it's a serializable field that shows a familiar object-picker in the Inspector, yet stores an Addressables key rather than a hard reference โ€” so assigning it does not pull the asset into memory. It's the designer-friendly way to point at Addressable content.

using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class Spawner : MonoBehaviour
{
    // Drag an Addressable prefab here in the Inspector โ€” stored as a key, not a hard link.
    public AssetReferenceGameObject enemyRef;

    AsyncOperationHandle<GameObject> handle;

    async void Start()
    {
        // AssetReference has its own load/instantiate helpers.
        handle = enemyRef.InstantiateAsync(transform.position, Quaternion.identity);
        await handle.Task;
    }

    void OnDestroy()
    {
        if (handle.IsValid())
            enemyRef.ReleaseInstance(handle);   // release via the reference
    }
}

AssetReference comes in typed flavours โ€” AssetReferenceGameObject, AssetReferenceTexture, AssetReferenceSprite โ€” that restrict the picker to the right asset type, catching mistakes at edit time. The same release rule applies: whatever you loaded or instantiated through the reference, you release. This is the field type the mini-project's level-layout asset uses to remember which Addressable each placement marker points to.

Catalogs & Remote Content

How does the runtime know that "Enemies/Goblin" maps to a particular file in a particular bundle, possibly on a server? Through a content catalog โ€” a small JSON manifest, built alongside your bundles, that maps every address and label to its bundle and dependencies. At startup Addressables loads the catalog; from then on, any key you request is resolved through it.

๐Ÿ“– Definition โ€” content catalog

The lookup table Addressables ships with your content: address/label โ†’ bundle โ†’ dependencies. It can be local (in the build) or remote. A remote catalog is the mechanism behind live content updates โ€” point the app at a new catalog on your server and it discovers new or changed content without a new app build.

When content is remote, you often want to preload it โ€” download a level's bundles up front (behind a progress bar) so gameplay never stalls mid-scene waiting on a network fetch. That's DownloadDependenciesAsync, which downloads (and caches) everything a key or label needs without instantiating anything:

async Task PreloadLevelAsync()
{
    // Download every bundle tagged "level" into the cache, with progress.
    var handle = Addressables.DownloadDependenciesAsync("level");

    while (!handle.IsDone)
    {
        float pct = handle.PercentComplete;   // drive a loading bar
        // ... update UI ...
        await Task.Yield();
    }

    // Optional: check the total size beforehand with GetDownloadSizeAsync(key).
    Addressables.Release(handle);             // release the download op when done
}

Once dependencies are downloaded and cached, subsequent LoadAssetAsync/InstantiateAsync calls for those keys resolve from the local cache and complete quickly. Downloaded bundles persist between sessions, so you pay the network cost once. Together, remote catalogs plus DownloadDependenciesAsync are how live-service games patch content, run seasonal events, and ship optional DLC โ€” all without a store resubmission.

๐Ÿ’ก You don't need a server to learn this. During development, groups set to "remote" can be served from a local folder or Unity's built-in hosting service, and the catalog/download code is identical to production. Build the habits โ€” addresses, handles, releases, preloading โ€” locally, and they carry straight over to a real CDN.

Hands-on Challenge

๐Ÿ‹๏ธ Exercise 1: Load, spawn, and release cleanly

Objective: Write a component that owns an Addressable instance for its whole lifetime and never leaks.

Create a PickupSpawner : MonoBehaviour with an AssetReferenceGameObject pickupRef. On Start, instantiate the pickup asynchronously and store the handle. On OnDestroy, release the instance. Add a bool that logs the load's PercentComplete each frame while it's still loading.

๐Ÿ’ก Hint

Keep the AsyncOperationHandle<GameObject> as a field. Await handle.Task in an async void Start. In OnDestroy, guard with if (handle.IsValid()) before pickupRef.ReleaseInstance(handle) โ€” releasing an invalid handle throws.

โœ… Solution sketch
public class PickupSpawner : MonoBehaviour
{
    public AssetReferenceGameObject pickupRef;
    AsyncOperationHandle<GameObject> handle;

    async void Start()
    {
        handle = pickupRef.InstantiateAsync(transform.position, Quaternion.identity);
        while (!handle.IsDone)
        {
            Debug.Log($"Loading pickup: {handle.PercentComplete:P0}");
            await Task.Yield();
        }
        if (handle.Status != AsyncOperationStatus.Succeeded)
            Debug.LogError("Pickup failed to load");
    }

    void OnDestroy()
    {
        if (handle.IsValid())
            pickupRef.ReleaseInstance(handle);   // destroys instance + drops ref count
    }
}

๐Ÿ‹๏ธ Exercise 2: Diagnose the leak

A teammate reports that memory climbs steadily as enemies spawn and die during a wave. Their spawn/despawn code is: var h = Addressables.InstantiateAsync("Enemies/Goblin"); ... // on death: Destroy(goblinGameObject);. What's wrong, and what's the fix?

โœ… Answer

They spawned with InstantiateAsync (ref count +1) but killed the object with plain Destroy(), which removes the GameObject without decrementing the Addressables reference count. The bundle's count never returns to zero, so the asset โ€” and every dependency โ€” stays resident; each wave leaks more. The fix is to despawn with Addressables.ReleaseInstance(goblinGameObject), which destroys the object and drops the count. Rule of thumb: what Addressables instantiated, Addressables must release.

๐ŸŽฏ Quick Quiz

Question 1: What's the main advantage of Addressables over dragging a prefab into a public reference field?

Question 2: You spawned an enemy with Addressables.InstantiateAsync. How should you remove it?

Question 3: What does Addressables.LoadAssetAsync<GameObject>("key") return?

Question 4: What is a remote content catalog for?

Summary

๐ŸŽ‰ Key Takeaways

  • Addressables gives you memory control (load on demand, unload on release), remote content, and decoupling from scenes and the Resources folder.
  • Assets carry an address (unique key) and labels (shared tags); groups decide bundling and local-vs-remote hosting โ€” invisible to loading code.
  • LoadAssetAsync<T>(key) and InstantiateAsync(key) return an AsyncOperationHandle<T>; await handle.Task for the result.
  • Always release: Addressables.Release(handle) for asset loads, Addressables.ReleaseInstance(go) for instantiations. Reference counting frees memory only at zero.
  • AssetReference (typed: AssetReferenceGameObject, โ€ฆ) is the Inspector-friendly, typo-proof way to point at Addressable content without hard-loading it.
  • Content catalogs map keys to bundles; remote catalogs enable live updates, and DownloadDependenciesAsync preloads/caches bundles behind a progress bar.

๐Ÿš€ What's Next?

You now have both halves of this module: the ability to build Editor tools (Lessons 7.1โ€“7.2) and to manage content asynchronously (this lesson). In Lesson 7.4: A Level-Layout Tool with Addressable Loading, you'll combine them โ€” an EditorWindow that lists Addressable prefabs and stamps placement markers into a saved layout asset, plus a runtime loader that reads that layout and instantiates each marker's Addressable, releasing every one on unload.

๐Ÿ“ฆ Content, under control

Load what you need, when you need it, and release it when you're done. Master that loop and your game's memory footprint becomes a decision you make, not an accident you suffer.