
In this post I describe a simple way to remove some byte[] allocations, no matter which version of .NET you're targeting, including .NET Framework. This will likely already be familiar to you if you write performance sensitive code with modern .NET, but I recently realised that this can be applied to older runtimes as well, like .NET Framework.
This post looks at the changes to your C# code to reduce the allocations, how the compiler implements the change behind the scenes, and some of the caveats and sharp edges to watch out for.
ReadOnlySpan<T> and Span<T> were introduced into .NET a long time ago now, but they have had a significant impact on the code you can (and arguably should) write, particularly when it comes to performance sensitive code. These provide a "window" or "view" over existing data, without creating copies of that data.
The classic example is when you're manipulating string objects; instead of using SubString(), and creating additional copies of segments of the string, you can use AsSpan() to create ReadOnlySpan<char>() segments that can be manipulated almost as though they are separate string instances, but without all the copying.
This is probably the most common use of Span<T> in application code, but fundamentally the use of Span<T> to provide a view over any piece of memory means it's useful in many other situations. The fact that the backing of a Span<T> can be almost anything, means you can keep the same "public" API which potentially "swapping out" the backend.
Another common example of this is if you have some parsing (or similar) code and you need a buffer to store the temporary results. Prior to Span<T>, you would almost certainly have allocated a normal array on the heap for this, but with Span<T>, "stack allocating" using stackalloc becomes just as easy, and reduces pressure on the garbage collector:
Span<byte> buffer = requiredSize <= 256
? stackalloc byte[requiredSize]
: new byte[requiredSize];
Virtually all new .NET runtime APIs are added with Span<T> or ReadOnlySpan<T> support, and you can even use them in old runtimes like .NET Framework via the System.Memory NuGet package (though you don't get all the same perf benefits that you do with .NET Core).
The ability to easily and safely (without needing directly falling back to unsafe and pointers) work with blocks of memory regardless of where they're from has really made Span<T> vital for any code that cares about performance. But this ability to provide an "arbitrary" view over memory also provides a way for the compiler to perform additional optimizations, as we'll see in the next section.
The ability for the compiler to provide a view over arbitrary memory is what drives the optimization I'm going to talk about for the rest of this post.
Let's imagine you have some byte[] that you need for something. Some kind of processing requires it. You know the data it needs to contain upfront, so you store the array in a static readonly field, so that the data is only created once:
public static class MyStaticData
{
private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };
}
This works absolutely fine, but it means when you first access that data, the runtime needs to create an instance of the array, fill it with the data, and store it in the field. After that, accessing the field is cheap, but the initial creation adds a small delay to the first use of that type.
However, starting with C# 8.0, and as long as that you only need a readonly view of the data, you can use a slightly different pattern, by exposing a ReadOnlySpan<byte> property instead of a field:
public static class MyStaticData
{
private static readonly byte[] ByteField = new byte[] { 1, 2, 3, 4 };
private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };
}
Now, normally, that's the sort of code that should be setting off performance alarm bells. It looks like it will be creating a new byte[] every time you access the property😱 But that's not what's happening.
We'll take a detailed look at the generated IL code shortly, for now we'll just talk at a high level.
When the compiler sees the pattern above, it does the following:
- Embed the
byte[] data into the final assembly's metadata - When
ReadOnlySpanProp is invoked, instead of creating a byte[], create a ReadOnlySpan<byte> that points directly to the data in the assembly
So the returned ReadOnlySpan<byte> isn't pointing to data that exists on the heap or even on the stack; it's pointing to data that's embedded directly in the assembly. That means there's no allocation at all, which removes that startup overhead and means there's no pressure at all on the garbage collector 🎉
It's worth noting as well that this is a compiler feature, which means that as long as a System.ReadOnlySpan<T> type is available, you can use it. So as long as you add the System.Memory NuGet package to your .NET Framework app, you too can benefit from this zero-allocation technique!
Also, this doesn't just apply to converting static readonly byte[] fields to static ReadOnlySpan<byte> properties; it also applies to local variables too. Which means things like the following, which look like they allocate an array, actually don't:
public static void TestData()
{
ReadOnlySpan<byte> arr = new byte[] { 0, 1, 2 };
}
Another minor thing to point out is that this also works with UTF-8 Strings Literals, which are logically represented as a byte[] by the type system. So this is also zero allocation:
public static class MyStaticData
{
private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => "Hello world"u8;
}
That's all great, but when I first used the byte[] approach, I was a little concerned. After all, it looked like it would be allocating and terribly inefficient, so I wanted to be sure. And what better way than checking the IL code the compiler generates.
There are multiple ways to check the generated code that the compiler generates. If you just want to check a "snippet" of code, then sharplab.io is a quick and easy option. Alternatively, there's ILSpy, or the JetBrains tools like dotPeek and Rider, and I'm sure Visual Studio has plugins for it.
To comfort myself, I first created a new .NET project using dotnet new classlib, and then I tweaked it to use .NET Framework. To be clear, the techniques shown so far work on all target frameworks, but I wanted to specifically test with .NET Framework, to prove that it's not just "new" frameworks this works with. I tweaked the project file as shown below:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Memory" Version="4.6.3" />
</ItemGroup>
</Project>
I then created the very simple class below, compiled, and used Rider to view the generated IL:
public static class MyStaticData
{
private static ReadOnlySpan<byte> ReadOnlySpanProp => new byte[] { 1, 2, 3, 4 };
private static ReadOnlySpan<byte> ReadOnlySpanUtf8 => "Hello world"u8;
}
I've commented the IL below to describe what it's doing, but the important thing is that we don't see any calls to newarr, InitializeArray(), or ToArray(), or other problematic calls. Instead, we see IL code that loads an address which points to data embedded in the PE image (i.e. the assembly), loads the length of the data (4 bytes), and then passes the pointer and length to the new ReadOnlySpan<T>() constructor and returns it. No copying, no new arrays, just a wrapper around bytes that are already loaded into memory 🎉
.class public abstract sealed auto ansi beforefieldinit
MyStaticData
extends [mscorlib]System.Object
{
.field private static initonly unsigned int8 One
.method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>
get_ReadOnlySpanProp() cil managed
{
.maxstack 8
IL_0000: ldsflda int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
IL_0005: ldc.i4.4
IL_0006: newobj instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)
IL_000b: ret
}
.method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>
get_ReadOnlySpanUtf8() cil managed
{
.maxstack 8
IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=12' '<PrivateImplementationDetails>'::'27518BA9683011F6B396072C05F6656D04F5FBC3787CF92490EC606E5092E326'
IL_0005: ldc.i4.s 11
IL_0007: newobj instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(void*, int32)
IL_000c: ret
}
}
Great, we can see that it's clearly working as we expected and this is .NET Framework, so it is just a compiler feature and has no runtime requirements, so we can use it everywhere.
But we need to be careful… I showed that it works for byte[], but it doesn't work for everything…
If you've read this far, you might be thinking "great, I'll use this for all my static array data", but I'm going to stop you there. Here-be dragons. The pattern above is only safe to use:
- If you have a
byte[], sbyte[], or bool[]. - If all the values in the array are constants
- If the array is immutable (i.e. you return a
ReadOnlySpan<T> not a Span<T>).
Breaking any of these rules may be disastrous for performance, so we'll examine each in turn.
The compiler optimizations shown so far can only be applied to byte-sized primitives, i.e. byte, sbyte, and bool. That's because the constant data would be stored in a little endian format, and needs to be translated to the runtime endian format, e.g. if the application is run on hardware which utilizes big endian numbers.
That means, that if you do the following (using int instead of byte), then the code compiles just fine, but unfortunately it doesn't generate the "zero allocation" code that you might expect:
public static class MyStaticData
{
private static ReadOnlySpan<int> ReadOnlySpanPropInt => new int[] { 1, 2, 3, 4 };
}
If we check the generated IL for a .NET Framework app with the above, we can see the problematic newarr and InitializeArray calls. The compiler actually does some work to avoid the really problematic pattern which would create an array every time, by creating the array once, caching it in a static field, and then using that cached data for subsequent calls, but it still has a startup cost, and does more work than the optimized byte[] approach:
.method private hidebysig static specialname valuetype [System.Memory]System.ReadOnlySpan`1<int32>
get_ReadOnlySpanPropInt() cil managed
{
.maxstack 8
IL_0000: ldsfld int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
IL_0005: dup
IL_0006: brtrue.s IL_0020
IL_0008: pop
IL_0009: ldc.i4.4
IL_000a: newarr [mscorlib]System.Int32
IL_000f: dup
IL_0010: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72
IL_0015: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_001a: dup
IL_001b: stsfld int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
IL_0020: newobj instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])
IL_0025: ret
}
So the "good" news is that this isn't much different to just using a static readonly int[], but it's still not ideal, and definitely isn't the zero-allocation version that you get with byte[].
Additionally, if you're on .NET 7+, a new API was added which actually does support this pattern. So if we change the target framework (to .NET 10 in this case), and recompile, then the IL is back to the zero allocation version, thanks to the call to RuntimeHelpers::CreateSpan, which handles fixing-up any endianness issues:
.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<int32>
get_ReadOnlySpanPropInt() cil managed
{
.maxstack 8
IL_0000: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16_Align=4' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B724
IL_0005: call valuetype [System.Runtime]System.ReadOnlySpan`1<!!0/*int32*/> [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::CreateSpan<int32>(valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_000a: ret
}
So in summary, your mileage will vary here, and you don't really gain anything unless you're on .NET 7+. If you need to target older frameworks, then you're potentially better off just sticking to a good old static readonly int[] field instead.
The next issue is that the whole approach shown in this post only works if all the values in the collection are constants. For example, the following example which uses a static readonly value inside the array compiles just fine:
public static class MyStaticData
{
private static readonly byte One = 1;
private static ReadOnlySpan<byte> ReadOnlySpanPropNonConstant => new byte[] { One, 2, 3, 4 };
}
but even on .NET 7+, this won't do the zero-allocation approach that you might be expecting. Instead, you get some really nasty "allocate a new array every time" behaviour 😱:
.method private hidebysig static specialname valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>
get_ReadOnlySpanPropNonConstant() cil managed
{
.maxstack 8
IL_0000: ldc.i4.4
IL_0001: newarr [System.Runtime]System.Byte
IL_0006: dup
IL_0007: ldtoken field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'
IL_000c: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0011: dup
IL_0012: ldc.i4.0
IL_0013: ldsfld unsigned int8 MyStaticData::One
IL_0018: stelem.i1
IL_0019: call valuetype [System.Runtime]System.ReadOnlySpan`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.ReadOnlySpan`1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])
IL_001e: ret
}
That's…bad 😬 And it does it on every property access. Definitely watch out for that one, on all target frameworks.
You have a similar "dangerous" scenario if you use Span<T> instead of ReadOnlySpan<T>:
public static class MyStaticData
{
private static Span<byte> SpanProp => new byte[] { 1, 2, 3, 4 };
}
In this case, because you're returning mutable data (Span<T> instead of ReadOnlySpan<T>), the compiler can't use any of its fancy tricks, because the data needs to be mutable. All it can do is create a new array, initialize it with the correct initial values, and then hand it back wrapped in a mutable Span<T>:
.method private hidebysig static specialname valuetype [System.Runtime]System.Span`1<unsigned int8>
get_SpanProp() cil managed
{
.maxstack 8
IL_0000: ldc.i4.4
IL_0001: newarr [System.Runtime]System.Byte
IL_0006: dup
IL_0007: ldtoken field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
IL_000c: call void [System.Runtime]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [System.Runtime]System.Array, valuetype [System.Runtime]System.RuntimeFieldHandle)
IL_0011: call valuetype [System.Runtime]System.Span`1<!0/*unsigned int8*/> valuetype [System.Runtime]System.Span`1<unsigned int8>::op_Implicit(!0/*unsigned int8*/[])
IL_0016: ret
}
The failure path here is understandable, because there's really no way to do a safe zero-allocation approach when the data needs to be mutable. The big problem is that it's not obvious that it's a super-allocatey property instead of a zero-allocation version. If you accidentally fat-finger and write Span<T> instead of ReadOnlySpan<T>, or, you know, Claude does, then it's really not obvious from simply reviewing the code…
The only good news is that if you use modern features, namely collection expressions, you might catch the issue!
So how do collection expressions help here? Well, those last two points, where one of the values isn't a constant, or where the variable is Span<T> instead of ReadOnlySpan<T> simply won't compile if you use the static property pattern with collection expressions:
public static class MyStaticData
{
private static ReadOnlySpan<byte> ReadOnlySpanPruopNonConstantCollectionExpression => [One, 2, 3, 4];
private static Span<byte> SpanPropCollectionExpression => [1, 2, 3, 4];
}
Attempting to compile this gives CS9203 errors:
Error CS9203 : A collection expression of type 'ReadOnlySpan<byte>' cannot be used in this context because it may be exposed outside of the current scope.
Error CS9203 : A collection expression of type 'Span<byte>' cannot be used in this context because it may be exposed outside of the current scope.

This gives you something of a safety-net. As long as you always use collection expressions for this scenario, you're blocked from making the most egregious errors. The case where you are using int is allowed, but as already flagged, that's not as bad, because it's actually supported on .NET 7+, and you still only create a single instance of the array and cache it in <.NET 7.
Unfortunately, collection expressions only save you in the static property case. If you are creating local variables, then collection expressions don't save you on .NET Framework (or on any .NET versions <.NET 8)
public static class MyStaticData
{
private static readonly byte One = 1;
public static void TestData()
{
ReadOnlySpan<int> intArray = [1, 2, 3, 4];
ReadOnlySpan<byte> nonConstantArray = [One, 2, 3, 4];
Span<byte> spanArray = [1, 2, 3, 4];
}
}
If we take a look at the IL generated for .NET Framework for this method, we can see that the int[] case uses the "create a static array and cache it" approach, while the non-constant and Span<T> cases create a new array every time, the same as happens with a static property:
.method public hidebysig static void
TestData() cil managed
{
.maxstack 5
.locals init (
[0] valuetype [System.Memory]System.ReadOnlySpan`1<int32> intArray,
[1] valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8> nonConstantArray,
[2] valuetype [System.Memory]System.Span`1<unsigned int8> spanArray
)
IL_0000: nop
IL_0001: ldsfld int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
IL_0006: dup
IL_0007: brtrue.s IL_0021
IL_0009: pop
IL_000a: ldc.i4.4
IL_000b: newarr [mscorlib]System.Int32
IL_0010: dup
IL_0011: ldtoken field valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=16' '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72
IL_0016: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_001b: dup
IL_001c: stsfld int32[] '<PrivateImplementationDetails>'::CF97ADEEDB59E05BFD73A2B4C2A8885708C4F4F70C84C64B27120E72AB733B72_A6
IL_0021: newobj instance void valuetype [System.Memory]System.ReadOnlySpan`1<int32>::.ctor(!0/*int32*/[])
IL_0026: stloc.0
IL_0027: ldloca.s nonConstantArray
IL_0029: ldc.i4.4
IL_002a: newarr [mscorlib]System.Byte
IL_002f: dup
IL_0030: ldtoken field int32 '<PrivateImplementationDetails>'::'1E6175315920374CAA0A86B45D862DEE3DDAA28257652189FC1DFBE07479436A'
IL_0035: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_003a: dup
IL_003b: ldc.i4.0
IL_003c: ldsfld unsigned int8 MyStaticData::One
IL_0041: stelem.i1
IL_0042: call instance void valuetype [System.Memory]System.ReadOnlySpan`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])
IL_0047: ldloca.s spanArray
IL_0049: ldc.i4.4
IL_004a: newarr [mscorlib]System.Byte
IL_004f: dup
IL_0050: ldtoken field int32 '<PrivateImplementationDetails>'::'9F64A747E1B97F131FABB6B447296C9B6F0201E79FB3C5356E6C77E89B6A806A'
IL_0055: call void [mscorlib]System.Runtime.CompilerServices.RuntimeHelpers::InitializeArray(class [mscorlib]System.Array, valuetype [mscorlib]System.RuntimeFieldHandle)
IL_005a: call instance void valuetype [System.Memory]System.Span`1<unsigned int8>::.ctor(!0/*unsigned int8*/[])
IL_005f: ret
}
So unfortunately, collection expressions don't save you here. Of course, you likely can (and should) be using stackalloc here for these small arrays, so this isn't necessarily a big deal. But you do need to know how to do this.
So what should we make of all this?
The good news is that if you use the right patterns, using static ReadOnlySpan<byte> properties to replace existing static readonly byte[] fields that contain read-only data can give a zero-allocation and essentially zero-startup cost improvement, even on .NET Framework.
However, if the field that you're "converting" is not byte[], bool[] or sbyte[], then you should think carefully about whether to convert it. int[] and other types are supported for similar optimizations on .NET 7+, but this requires runtime support, so if you're also targeting .NET Framework, .NET Standard, or .NET 6 and below, then I would seriously consider whether it's worth making the change.
You likely will see perf benefits on .NET 7+, but as far as I can tell, you're talking about a ~15% speed improvement for the initial creation of the array. But if you're calling RuntimeHelpers.CreateSpan() with every access, versus just loading a field, does that actually improve steady state performance? I don't know, I haven't checked, I'm just wondering😄
Where you really need to be careful is to only use constant values in your arrays (no static readonly values, please) and only use ReadOnlySpan<T>, not Span<T>. Luckily, you'll catch these automatically in your static properties if you're using collection expressions, as they simply won't compile. Which just another reason you should use collection expressions everywhere you can!😃
Replacing static byte[] fields with static ReadOnlySpan<byte> is probably the most common scenario you'll find, but you can also apply this to local variables. However, I suspect that scenario is going to be less common, simply because that's so clearly very allocating, it means that presumably you "don't care about performance" here, in which case there's no point making the ReadOnlySpan<byte> change.
There's another reason for not touching local definitions, which is that the collection expression "solution" described above doesn't cause compilation failures with local variables, so there isn't the same easy guardrails there.
If you're anything like me, then the fact that there are so many edge cases where you fall off a performance cliff is somewhat surprising. Generally the .NET team try quite hard to avoid these cliffs, or at least add analyzers to help steer you in the right direction. There seems to be little here to stop you doing the "wrong" thing.
Looking through the various issues and discussions, that's something that's come up multiple times, but it seems like the difficulty is generally "the problematic code patterns are actually valid sometimes". There's also the "well, you should be using stackalloc anyway" argument, as well as "collection expressions partially protect you":
So all-in-all, this approach seems to be "use at your own risk". I still think it might be nice to have optional analyzers at least to try to protect you (and maybe someone's already written those). Nevertheless, the ability to reduce initialization costs to 0 if you have a bunch of static data is definitely a win; just make sure you only use it in a safe way!