Overview
When downloading a large number of remote assets with Unity Addressables, you can limit the number of concurrent network requests by setting MaxConcurrentWebRequests.
However, this only controls the number of concurrent requests.
For example, suppose you download 500 AssetBundles at once. Even if the number of concurrent requests is limited to 3, the remaining 497 requests are still added to Addressables' internal request queue.
If the user presses a cancel button in this state, the queued requests inside Addressables are not automatically removed.
Depending on your implementation, the flow can look like this:
Start downloading 500 files
↓
3 requests start immediately
497 requests are queued in Addressables' internal WebRequestQueue
↓
The user cancels
↓
The app-side await / CancellationToken is treated as canceled
↓
However, the internal Addressables queue remains
↓
Every time a request slot becomes available, the next queued request starts
↓
Even after canceling, the app still waits for a large number of failed requests
Even if you limit the number of concurrent downloads with MaxConcurrentWebRequests, canceling can still take a very long time when the total number of downloads is large.
This article introduces an approach where we directly modify the internal Addressables code so that both queued requests and active requests can be canceled.
Assumptions
This article uses Addressables 2.9.1 as the example.
However, the internal implementation of Addressables can change depending on the version.
So rather than copying the code in this article as-is, you should first check the following points in the version used by your project:
Where the queued web requests are stored
Where UnityWebRequest is started
Where completion is reported back to AssetBundleProvider
Then adjust the implementation to match the Addressables version used in your project.
Why submit all requests first?
If you only care about cancellation, the safest approach is to create your own queue on the app side and submit only a few requests to Addressables at a time.
Start only 3 downloads
↓
When one finishes, start the next one
↓
If canceled, do not submit the remaining requests
With this approach, cancellation is simple.
However, in an actual game, you often want to display UI like this:
Total download size: 1.2 GB
Remaining download size: 850 MB
Progress: 29%
By using AsyncOperationHandle.GetDownloadStatus() in Addressables, you can obtain the downloaded byte count and the total byte count.
Because of that, there are cases where you want to pass the entire download target set to Addressables at the beginning, so that the progress UI can be calculated accurately.
In other words, the desired behavior is:
Pass the full download target set to Addressables for progress UI
Limit concurrent downloads with MaxConcurrentWebRequests
Immediately discard queued requests when canceled
Stop active requests as well if possible
Why modify Addressables directly instead of using a custom provider?
Addressables also allows you to create custom providers.
However, based on my experience, the internal implementation of Addressables has changed significantly several times across version updates.
Because of that, when modifying behavior that deeply depends on Addressables internals, it can sometimes be easier to manage the change by embedding the Addressables package into the project and directly modifying the relevant code, rather than trying to wrap everything with a custom provider.
Of course, this is more of a practical workaround than an officially recommended extension method.
Directly modifying the package has several downsides:
You must check diffs whenever Addressables is updated
The modified package is more likely to be outside official support
The team needs to understand and share the modification details
Unity / Addressables updates may require reapplying or rewriting the changes
Even so, if canceling a large download causes the app to wait for hundreds of failed network requests, directly modifying the package can be worth it.
Making Addressables editable
Addressables installed through Package Manager is normally placed under Library/PackageCache.
Packages in this state are not meant to be edited directly.
If you want to edit Addressables, embed it into the project's Packages folder.
The official Unity workflow mainly has two options:
1. Use the Package Manager Scripting API Client.Embed
2. Manually copy the package from Library/PackageCache to the Packages folder
Method 1: Use Client.Embed
Unity provides the Client.Embed API to embed an installed package into the project.
By creating an Editor script like the following, you can embed Addressables from a menu item.
#if UNITY_EDITOR
using UnityEditor;
using UnityEditor.PackageManager;
using UnityEditor.PackageManager.Requests;
using UnityEngine;
public static class EmbedAddressablesPackageMenu
{
private static EmbedRequest _request;
[MenuItem("Tools/Addressables/Embed Addressables Package")]
private static void EmbedAddressables()
{
if (_request != null && !_request.IsCompleted)
{
Debug.LogWarning("Addressables package embed is already running.");
return;
}
_request = Client.Embed("com.unity.addressables");
EditorApplication.update += WaitForEmbedRequest;
}
private static void WaitForEmbedRequest()
{
if (_request == null || !_request.IsCompleted)
return;
EditorApplication.update -= WaitForEmbedRequest;
if (_request.Status == StatusCode.Success)
{
Debug.Log($"Embedded package: {_request.Result.packageId}");
}
else
{
Debug.LogError($"Failed to embed Addressables package: {_request.Error.message}");
}
_request = null;
}
}
#endif
Client.Embed copies the target package into the Packages folder and makes it editable.
Method 2: Manually copy the package
If you want to do it manually, first install Addressables normally from Package Manager.
Then copy the Addressables folder under Library/PackageCache into the Packages folder.
Example:
Library/PackageCache/com.unity.addressables@8460f1c9c927
↓
Packages/com.unity.addressables
The important point is that the destination folder name should usually be com.unity.addressables.
Embedded packages under the Packages folder take precedence in Unity.
Because of that, even if Packages/manifest.json remains unchanged, the embedded package is used first.
{
"dependencies": {
"com.unity.addressables": "2.9.1"
}
}
If you want to explicitly reference it as a local package, you can also write it like this:
{
"dependencies": {
"com.unity.addressables": "file:com.unity.addressables"
}
}
However, if you simply want it to be treated as an embedded package, placing it at Packages/com.unity.addressables is usually enough.
Modification policy
In Addressables 2.9.1, WebRequestQueue.cs has the following management structure:
internal static int s_MaxRequest = 3;
internal static Queue<WebRequestQueueOperation> s_QueuedOperations = new Queue<WebRequestQueueOperation>();
internal static List<UnityWebRequestAsyncOperation> s_ActiveRequests = new List<UnityWebRequestAsyncOperation>();
Roughly speaking, they mean:
s_QueuedOperations = requests waiting in the queue that have not called SendWebRequest yet
s_ActiveRequests = requests that have already called SendWebRequest and are currently running
What we want to do is:
Queued requests:
Do not start communication, and notify AssetBundleProvider that they were canceled
Active requests:
Stop them with UnityWebRequest.Abort()
To do that, we add the following changes:
1. Add OnCancel to WebRequestQueueOperation
2. Add AbortRequest() to WebRequestQueue
3. Register cancellation handling in AssetBundleProvider through OnCancel
Modifying WebRequestQueue.cs
First, add OnCancel to WebRequestQueueOperation.
public class WebRequestQueueOperation
{
private bool m_Completed = false;
/// <summary>
/// Stores the async operation object returned from sending the web request.
/// </summary>
public UnityWebRequestAsyncOperation Result;
/// <summary>
/// Event that is invoked when the async operation is complete.
/// </summary>
public Action<UnityWebRequestAsyncOperation> OnComplete;
/// <summary>
/// Event that is invoked when the queued request is canceled before sending.
/// </summary>
public Action OnCancel { get; set; }
/// <summary>
/// Indicates that the async operation is complete.
/// </summary>
public bool IsDone
{
get { return m_Completed || Result != null; }
}
internal UnityWebRequest m_WebRequest;
/// <summary>
/// The web request.
/// </summary>
public UnityWebRequest WebRequest
{
get { return m_WebRequest; }
internal set { m_WebRequest = value; }
}
public WebRequestQueueOperation(UnityWebRequest request)
{
m_WebRequest = request;
}
internal void Complete(UnityWebRequestAsyncOperation asyncOp)
{
m_Completed = true;
Result = asyncOp;
OnComplete?.Invoke(Result);
}
}
Next, add AbortRequest() to WebRequestQueue.
public static class WebRequestQueue
{
internal static int s_MaxRequest = 3;
internal static Queue<WebRequestQueueOperation> s_QueuedOperations = new Queue<WebRequestQueueOperation>();
internal static List<UnityWebRequestAsyncOperation> s_ActiveRequests = new List<UnityWebRequestAsyncOperation>();
/// <summary>
/// Cancels queued requests and aborts active requests.
/// </summary>
public static void AbortRequest()
{
// Cancel requests that are waiting in the queue and have not started communication yet.
var queuedOperations = s_QueuedOperations.ToArray();
s_QueuedOperations.Clear();
foreach (var operation in queuedOperations)
{
operation.OnCancel?.Invoke();
}
// Abort requests that have already started communication.
foreach (var operation in s_ActiveRequests.ToArray())
{
operation.completed -= OnWebAsyncOpComplete;
operation.webRequest.Abort();
// Remove it from the active request list.
// Since the queued operations have already been cleared,
// this won't start the next queued request.
OnWebAsyncOpComplete(operation);
}
}
}
queuedOperations is copied with ToArray() before calling Clear().
This avoids modifying the queue while it is being enumerated, because Addressables-side completion handling may run inside OnCancel.
For active requests, Abort() is called.
At that point, operation.completed -= OnWebAsyncOpComplete; removes the completion callback from WebRequestQueue, and then OnWebAsyncOpComplete(operation) is called manually.
The purpose is to remove the request from s_ActiveRequests.
Because s_QueuedOperations.Clear() has already been called, this does not start the next queued request.
Modifying AssetBundleProvider.cs
Next, register cancellation handling in AssetBundleProvider through OnCancel.
In 2.9.1, there is a place where WebRequestQueueOperation is received and BeginWebRequestOperation is registered to OnComplete.
Add the cancellation handling there.
internal void AddBeginWebRequestHandler(WebRequestQueueOperation webRequestQueueOperation)
{
if (webRequestQueueOperation.IsDone)
{
BeginWebRequestOperation(webRequestQueueOperation.Result);
}
else
{
#if ENABLE_PROFILER
AddBundleToProfiler(Profiling.ContentStatus.Queue, m_Source);
#endif
webRequestQueueOperation.OnComplete += asyncOp => BeginWebRequestOperation(asyncOp);
// Register cancellation handling for queued requests.
webRequestQueueOperation.OnCancel += () =>
{
// Do not call the normal web request completion path.
// This request was canceled before SendWebRequest was called.
webRequestQueueOperation.OnComplete = null;
// Notify Addressables that the provide operation has failed.
m_ProvideHandle.Complete<AssetBundleResource>(null, false, null);
#if ENABLE_CACHING
if (!string.IsNullOrEmpty(m_Options.Hash))
{
#if ENABLE_PROFILER
if (m_Source == BundleSource.Cache)
#endif
{
// Even when the operation fails, a null-like cache state can remain.
// If the same address is loaded or downloaded again, that cached null can be returned immediately.
// Clear the cached version to avoid that state.
var locHash = Hash128.Parse(m_Options.Hash);
if (!locHash.isValid ||
!Caching.IsVersionCached(new CachedAssetBundle(m_Options.BundleName, locHash)))
{
Caching.ClearCachedVersion(m_Options.BundleName, locHash);
}
}
}
#endif
};
}
}
The important point is that the normal OnComplete path must not be called when canceled.
Requests waiting in the communication queue have not called SendWebRequest() yet.
Therefore, instead of sending them to the normal request completion path, we notify Addressables of failure by calling m_ProvideHandle.Complete<AssetBundleResource>(null, false, null).
Usage
After this change, call WebRequestQueue.AbortRequest() when the user wants to cancel communication.
using UnityEngine.ResourceManagement;
public sealed class DownloadCancelButton
{
public void CancelDownload()
{
WebRequestQueue.AbortRequest();
}
}
This allows both of the following to be canceled:
Queued requests:
Call OnCancel and notify Addressables that the operation failed
Active requests:
Stop communication with UnityWebRequest.Abort()
Note: This implementation is a global cancellation
The AbortRequest() implementation above cancels all requests currently stored in WebRequestQueue.
This is simple when you want to stop all Addressables communication, but it is not suitable if multiple download processes are running at the same time and you only want to cancel one of them.
If you want partial cancellation, extend the method so that it can filter requests by URL or internal ID.
For example, you can add a Predicate<UnityWebRequest> parameter like this:
public static void AbortRequest(Predicate<UnityWebRequest> predicate)
{
var queuedOperations = s_QueuedOperations.ToArray();
s_QueuedOperations.Clear();
foreach (var operation in queuedOperations)
{
if (predicate == null || predicate(operation.WebRequest))
{
operation.OnCancel?.Invoke();
}
else
{
s_QueuedOperations.Enqueue(operation);
}
}
foreach (var operation in s_ActiveRequests.ToArray())
{
if (predicate != null && !predicate(operation.webRequest))
continue;
operation.completed -= OnWebAsyncOpComplete;
operation.webRequest.Abort();
OnWebAsyncOpComplete(operation);
}
}
Usage example:
WebRequestQueue.AbortRequest(request =>
{
return request != null &&
request.url.Contains("/addressables/");
});
Filtering by your project's CDN path or version path can reduce the risk of stopping unintended requests.
Note: Retry behavior for active requests
Queued requests are explicitly completed as failed through OnCancel.
On the other hand, requests that are already active are interrupted with UnityWebRequest.Abort().
In that case, they may still flow into the normal communication failure handling path inside Addressables.
Depending on the AssetBundleProvider settings or the Addressables version, they may be retried if retry settings are enabled.
If you also want active requests to be treated as a true "user cancellation", add a separate cancellation flag and branch inside WebRequestOperationCompleted so that it does not enter the normal retry path.
The general idea is:
Set a cancellation flag when WebRequestQueue.AbortRequest() is executed
↓
Check whether cancellation is active inside AssetBundleProvider's request completion handling
↓
If canceled, do not treat it as a normal network error or retry target.
Complete it as failed instead
This part differs depending on the Addressables version, so it is safest to check the AssetBundleProvider.cs used by your project and adjust accordingly.
Note: Clearing the cache
Even when a request is canceled or fails, a partially invalid state can remain on the cache side.
If the same address is loaded or downloaded again in that state, the invalid cache state may be returned immediately.
Because of that, this example clears the target Bundle cache with Caching.ClearCachedVersion when canceled.
var locHash = Hash128.Parse(m_Options.Hash);
if (!locHash.isValid ||
!Caching.IsVersionCached(new CachedAssetBundle(m_Options.BundleName, locHash)))
{
Caching.ClearCachedVersion(m_Options.BundleName, locHash);
}
Depending on the project, this may not be necessary.
However, if the same data may be downloaded again after canceling a large download, it is worth checking how the cache behaves.
Summary
When downloading a large number of remote assets with Addressables, queued requests are stored inside Addressables even if MaxConcurrentWebRequests is set.
Because of that, if you submit 500 downloads at once and then cancel them, the remaining requests may still be processed one by one, causing cancellation to take a long time.
If you want to pass all targets to Addressables for progress UI, app-side queue control alone may not be enough.
In this approach, we directly modify WebRequestQueue and AssetBundleProvider in Addressables 2.9.1 and add the following behavior:
Queued requests:
Call OnCancel and complete them as failed before communication starts
Active requests:
Stop communication with UnityWebRequest.Abort()
With this change, both queued requests and active requests can be stopped when the user cancels.
However, the internal implementation of Addressables differs depending on the version.
Treat the code in this article as one example for 2.9.1, and adjust it to match the Addressables version used by your project.
References
- Unity Manual: Embedded dependencies https://docs.unity3d.com/ja/2023.2/Manual/upm-embed.html
- Unity Scripting API: PackageManager.Client.Embed https://docs.unity3d.com/2023.2/Documentation/ScriptReference/PackageManager.Client.Embed.html
- Unity Manual: Local folder or tarball paths https://docs.unity.cn/Components/upm-localpath.html
- Addressables: Asynchronous operation handles https://docs.unity.cn/Packages/com.unity.addressables@2.2/manual/AddressableAssetsAsyncOperationHandle.html
Top comments (0)