๐ฆ 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
Resourcesfolder for memory and content control - Describe addresses, labels, groups, and the
AssetReferenceserialized field - Load an asset with
Addressables.LoadAssetAsync<T>(key)andawaititsAsyncOperationHandle<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:
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 withAddressables.Release(handle). - Spawned with
InstantiateAsyncโ release withAddressables.ReleaseInstance(go)(this also destroys the object). Calling plainDestroy()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.
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
Resourcesfolder. - 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)andInstantiateAsync(key)return anAsyncOperationHandle<T>;await handle.Taskfor 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
DownloadDependenciesAsyncpreloads/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.