Async Build Size

The Cost Of async/await On The Build Size

More and more Unity projects use Task-based Asynchronous Pattern (TAP) and async/await to work with asynchronous methods. It greatly simplifies code and cognitive load on a developer compared to other approaches such as Event-based Asynchronous Pattern (EAP), Asynchronous Programming Model (APM), or Coroutines. Calling async methods has some performance cost, but it doesn’t seem to make a big difference unless you are running it millions of times per frame. However, knowing how async/await works under the hood, I was interested in understanding the impact on the build size, as the compiler does its magic and for each async method generates the state machine consisting of many lines of code.

In this post, we will look at builds targeting Standalone and Android using IL2CPP scripting backend.
The Unity version is 2021.3.14f1.

Table of Contents


Setup

We will create a MonoBehaviour class that will be attached to an empty game object in the default sample scene. The class will start off empty, but we will add to it later to see how it affects the build size. We will compare the results when using standard C# Tasks and UniTask. There is also a separate asmdef named TestAssembly so that our test code is converted into a separate C++ file which is by no surprise would be named TestAssembly.cpp.
Since UniTask might be stripped completely or partly based on the actual usage, I would like to always have the whole package included in the build, so the size won’t fluctuate due to the package code. In order to do this we need to add a link.xml file into the Assets folder:

<linker>
  <assembly fullname="System.Threading.Tasks" preserve="all"/>
  <assembly fullname="UniTask" preserve="all"/>
</linker>



How async/await affect the Standalone build size?

Here, we will examine the total size of the uncompressed output that is sent to an end user. Typically, this output is compressed and distributed with some kind of installer, but since compression algorithms can vary, we will omit that step and focus on the raw output instead.

Let’s check what code is generated when we just return the UniTask without using async/await:

    private void Start()
    {
        TestMethodAsync();
    }

    private UniTask TestMethodAsync()
    {
        return UniTask.Delay(100);
    }


When we just return the UniTask without awaiting we won’t have an async state machine generated. It is really easy to check by looking at the output C++ code. Here is the generated TestMethodAsync, and I have highlighted where the result is returned.

// Cysharp.Threading.Tasks.UniTask TestTaskMonoBehaviour::TestMethodAsync()
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR UniTask_t8E1453C1D8424B1FC22B0E51B017D3B028E17270 TestTaskMonoBehaviour_TestMethodAsync_m1AE29B2C8D748AD609313BFCB382742B1437708E (TestTaskMonoBehaviour_t8D0CF54D6090450EE844BEA7DA0A080B5A17A74C* __this, const RuntimeMethod* method) 
{
	static bool s_Il2CppMethodInitialized;
	if (!s_Il2CppMethodInitialized)
	{
		il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&UniTask_t8E1453C1D8424B1FC22B0E51B017D3B028E17270_il2cpp_TypeInfo_var);
		s_Il2CppMethodInitialized = true;
	}
	CancellationToken_t51142D9C6D7C02D314DA34A6A7988C528992FFED V_0;
	memset((&V_0), 0, sizeof(V_0));
	{
		// return UniTask.Delay(100);
		il2cpp_codegen_initobj((&V_0), sizeof(CancellationToken_t51142D9C6D7C02D314DA34A6A7988C528992FFED));
		CancellationToken_t51142D9C6D7C02D314DA34A6A7988C528992FFED L_0 = V_0;
		il2cpp_codegen_runtime_class_init_inline(UniTask_t8E1453C1D8424B1FC22B0E51B017D3B028E17270_il2cpp_TypeInfo_var);
		UniTask_t8E1453C1D8424B1FC22B0E51B017D3B028E17270 L_1;
		L_1 = UniTask_Delay_m3D58C4E2738CAD61F29C9292DADAFAFB5DAC5C2A(((int32_t)100), (bool)0, 8, L_0, NULL);
		return L_1;
	}
}

For reference, the total size of the TestAssembly.cpp is 257 lines in this case. In a similar case when Task and Task.Delay is used the resulting class consists of 274 lines of code and doesn’t differ too much.
Now let’s try async/await:

    private void Start()
    {
        TestMethodAsync();
    }

    private async UniTask TestMethodAsync()
    {
        await UniTask.Delay(100);
    }

TestAssembly.cpp grows to 1102 and 670 lines of code for UniTask and Task examples respectively. And indeed there is a generated async state machine that resembles the one described in Dissecting the async methods in C#. You definitely don’t need to look at it, but if you wish to check how our test method and the state machine look now, here you go excerpt of a generated C++ class.
And here are the results:

| Async usage          | Build size                 |
|----------------------| ----------- ---------------|
| empty                | 58.2 MB (61,049,171 bytes) |
| return UniTask       | 58.2 MB (61,049,767 bytes) |
| return Task          | 58.2 MB (61,049,807 bytes) |
| async-await UniTask  | 58.2 MB (61,056,211 bytes) |
| async-await Task     | 58.2 MB (61,062,043 bytes) |

So 1 async/await with UniTask adds 7040 bytes or roughly 6.9KB to the total output size.
And async/await with Task adds 12872 bytes or 12.6KB. These values are true for my exact case and may fluctuate depending on the actual code you have. Also, multiple awaits inside one asynchronous method are compiled into one state machine. Anyway, it should give you a general idea of how the code size grows when adding an async method.


How async/await affect the Android build size?

Here it gets more interesting as Google Play has a cellular over-the-air download limit of 100MB for APK and 150MB for AAB. So developers try to be below this limit to avoid a warning shown to users when downloading the game.
I expect the same build size increase here, but smaller by some factor since APK is compressed.
Android build settings: IL2CPP, IL2CPP code generation: faster runtime, architecture: armv7, stripping: high, compression method: LZ4.

| Async usage          | APK size                   |
|----------------------| ---------------------------|
| empty                | 13.2 MB (13,924,117 bytes) |
| return UniTask       | 13.2 MB (13,924,117 bytes) |
| return Task          | 13.2 MB (13,924,117 bytes) |
| async-await UniTask  | 13.2 MB (13,924,117 bytes) |
| async-await Task     | 13.2 MB (13,924,117 bytes) |

We can see that adding more code doesn’t cause the size to increase, even though the state machine is definitely present in the output and not stripped. I guess it is due to the compression method that uses 4KB blocks (correct me in the comments if I am wrong), so 1 await after compression is insufficient to cause the 4KB increase. To examine the impact I will increase the amount of async/await methods one by one to find how many it takes to bump the size by another 4KB.

| async/await count    | APK size                   |
|----------------------|----------------------------|
| 1                    | 13.2 MB (13,924,117 bytes) |
| 2                    | 13.2 MB (13,924,117 bytes) |
| 3                    | 13.2 MB (13,928,213 bytes) |
| 4                    | 13.2 MB (13,932,309 bytes) |
| 5                    | 13.2 MB (13,936,405 bytes) |
| 6                    | 13.2 MB (13,936,405 bytes) |
| 7                    | 13.2 MB (13,936,405 bytes) |
| 8                    | 13.2 MB (13,940,501 bytes) |

According to the results, on average every two async/await added to a class result in 4KB added to the APK size.
Is it a lot? Well, it means 51200 async/await fit into 100MB, but you obviously won’t ship only async state machines to your users. So subtract around 11MB of default Unity stuff that goes into APK even when you build a clean project with high stripping. Then add assets and other code you would have.
Based on my experience I hardly see the code generation to be an issue compared to assets as assets are always the most significant size contributor as well as the most obvious target to start optimizing the build size. However, I should say that I saw projects that were really struggling with the code size, but still not to the point that it took more space than assets.
Anyway we can also change IL2CPP code generation to Faster (smaller) builds. Let’s see how this affects our test:

| async/await count   | APK size                   |
|---------------------|----------------------------|
| 1                   | 10.6 MB (11,159,317 bytes) |
| 2                   | 10.6 MB (11,159,317 bytes) |
| 3                   | 10.6 MB (11,159,317 bytes) |
| 4                   | 10.6 MB (11,159,317 bytes) |
| 5                   | 10.6 MB (11,159,317 bytes) |
| 6                   | 10.6 MB (11,159,317 bytes) |
| 7                   | 10.6 MB (11,163,413 bytes) |
...
| 10                  | 10.6 MB (11,163,413 bytes) |
| 11                  | 10.6 MB (11,163,413 bytes) |
| 12                  | 10.6 MB (11,163,413 bytes) |
| 13                  | 10.6 MB (11,167,509 bytes) |
...
| 16                  | 10.6 MB (11,167,509 bytes) |

Now six state machines fit into a 4KB block, which makes the possible concern about the build size effect non-existent.


Conclusion

Actually, I expected the size increase to be lower than 2KB on Android and 7-12KB on Standalone. Anyway, I believe you should not worry about it at all. On PC even tens of thousands of generated async state machines won’t make a big difference when some modern games require you to download more than 200GB. Most PC users won’t even pay attention if any small game download size is 1GB or 1.1GB. On Android the capacity is smaller though. But again I can only imagine someone being on the edge of the download limit when it’s a matter of kilobytes and removing a few awaits would let squeeze a build into the desired limit. Usually, any sort of asset manipulation (changing a compression, using remote asset bundles, etc) gives better results.
So don’t worry and use async/await.


AlexeyMerzlikin-TechLead-Gamedeveloper-Unity


Alexey Merzlikin

Experienced game developer and tech lead with a passion for writing educational content about game development and programming. I have over 9 years of industry experience focused on performance optimization, clean code practices, and robust game architecture. I share regular posts on my Telegram channel about applying software engineering best practices to build high-quality games. My goal is to help other game developers level up their skills.