ref struct, readonly struct, scoped, and stack-only types in C#
This is one of those areas in modern C# that looks small on the surface, but actually tells you a lot about how .NET evolved.
These features were not added because ordinary business code suddenly needed new syntax. They were added because .NET needed a safer way to write code that works close to memory, avoids unnecessary allocations, and still feels like C# instead of C with extra steps.
If you are building CRUD APIs, you can go a long time without caring.
If you are building a high-throughput parser, an image-processing loop, a binary protocol reader, or a real-time machine application that runs all day and handles large buffers, these features start to matter a lot.
Part 1 — Big picture
Why stack vs heap matters in .NET
At a high level, .NET gives you two very different kinds of memory behavior:
Stack-like lifetime
- very short-lived
- tied to method execution
- no GC tracking needed
- fast creation and cleanup
Heap lifetime
- objects can outlive the current method
- tracked by the garbage collector
- flexible, but more expensive over time
- can contribute to allocation pressure and GC pauses
This distinction matters because performance problems in real systems are often not caused by one huge allocation. They are caused by millions of small temporary allocations.
A loop that runs thousands of times per second and creates tiny objects, substrings, arrays, or wrappers can quietly generate a lot of garbage. That garbage then becomes GC work. And GC work becomes pauses, CPU cost, cache churn, and latency spikes.
In a real-time or long-running system, that matters more than people think.
Why avoiding allocations is important in real-time and long-running systems
In a short-lived web request, a few small allocations may not matter much.
In a long-running WPF desktop app controlling hardware, they can matter a lot because:
- the process stays alive for hours or days
- hot paths run continuously
- image data and telemetry buffers are large
- operators notice jitter, lag, and UI stutters
- GC pressure accumulates over time
Think about a wafer inspection system:
- camera frames keep arriving
- metadata packets keep arriving
- results are parsed, transformed, filtered, and displayed
- background loops run constantly
- the app may stay open for an entire shift or longer
If every stage creates little temporary heap objects, the system may still “work,” but it becomes less stable under load.
Why C# introduced stack-only constructs
C# originally gave you:
- reference types on the heap
- value types with copy semantics
- unsafe code if you really needed raw memory access
That left a gap.
Developers needed a way to work with:
- slices of arrays
- temporary views over buffers
- stack memory
- unmanaged memory
- parsing without copying
But they needed it in a way that stayed safe.
That is where stack-only constructs came in.
The runtime and language added mechanisms that let you say:
“This thing is only valid for a short, local lifetime. Please let me use it efficiently, but do not let it escape somewhere dangerous.”
That is the core idea behind ref struct, Span<T>, and later scoped.
How this connects to Span<T> and high-performance APIs
Span<T> is one of the most important modern .NET performance features.
It gives you a lightweight view over contiguous memory:
- an array
- part of an array
- stack memory
- unmanaged memory
It lets you process slices of data without allocating and often without copying.
That is incredibly useful for:
- parsing binary data
- text processing
- image row access
- protocol decoding
- temporary work buffers
- tight loops over chunks of memory
But that power is dangerous if the language lets the view outlive the memory it points to.
So C# had to add rules that keep these memory views safe.
That is why stack-only types exist.
Part 2 — What is a ref struct
A ref struct is a special kind of struct with extra lifetime restrictions.
A normal struct is a value type. It can:
- live inside another object
- be boxed
- be stored in arrays
- be used in async methods
- be captured by lambdas
- end up on the heap in various ways
A ref struct cannot.
It is designed to stay in places where the compiler can reason about its lifetime safely.
Simple mental model
A normal struct is:
“A value type with copy semantics.”
A ref struct is:
“A value type that may refer to short-lived memory, so the language prevents it from escaping to places where that memory could become invalid.”
That is the important part: the restriction is not arbitrary. It exists because these types often represent memory that is only temporarily valid.
Example idea
You can imagine a type like this:
public ref struct BufferWindow
{
private Span<byte> _buffer;
public BufferWindow(Span<byte> buffer)
{
_buffer = buffer;
}
public Span<byte> Slice(int offset, int length) => _buffer.Slice(offset, length);
}This type is not just carrying values. It is carrying a live view over memory.
If that memory came from:
stackalloc- a temporary stack frame
- a borrowed region of a larger buffer
- unmanaged memory managed elsewhere
then letting BufferWindow escape freely would be unsafe.
So C# says: this kind of type must stay constrained.
Why it is restricted to the stack
People often say “ref struct lives on the stack,” which is directionally useful, but not perfectly precise in every implementation detail.
The real idea is:
It is restricted so it cannot be heap-allocated or used in situations that would require heap lifetime semantics.
That usually means local, temporary, stack-safe usage.
How it differs from normal struct
A normal struct is mainly about value semantics.
A ref struct is about value semantics plus strict lifetime control.
That extra control is what makes types like Span<T> possible.
Why it cannot be boxed, captured, or stored on heap
Because all of those operations would allow the value to outlive the memory it may point to.
If a ref struct contains a reference to stack memory and you:
- box it as
object - store it in a class field
- capture it in a lambda
- let it cross an async boundary
then you could end up with a reference that survives after the original memory is gone.
That would be memory corruption territory.
C# prevents that at compile time.
Part 3 — Why stack-only matters
Lifetime guarantees
Stack-only types give the compiler a powerful guarantee:
this value cannot outlive the scope where it is valid.
That is huge.
Without that guarantee, you cannot safely expose temporary memory views in a high-level language.
Avoiding GC tracking
If you can process data using stack-local views and slices instead of creating heap objects, you reduce the amount of garbage the GC has to track.
That means:
- fewer temporary allocations
- less promotion pressure
- less cleanup work
- more stable latency
For hot loops, that matters more than raw CPU instruction count.
Predictable performance
Heap allocation in .NET is usually fast. That part is true.
The problem is not one allocation. The problem is the downstream effect of allocation patterns over time.
Stack-local views tend to be more predictable because:
- they do not need object headers
- they do not create collectible garbage
- cleanup is tied to scope ending
- they reduce allocation churn
That predictability is especially valuable in real-time and hardware-integrated systems.
Preventing unsafe memory usage
This is the part many people miss.
Stack-only is not just a performance trick. It is also a safety model.
Span<T> can point at:
- managed arrays
- stack memory
- unmanaged memory
That flexibility is only acceptable because the language enforces rules that prevent misuse.
Example: temporary parsing buffer
Suppose you read a binary packet into a local buffer and create spans over parts of it:
Span<byte> packet = stackalloc byte[64];
Span<byte> header = packet.Slice(0, 8);
Span<byte> payload = packet.Slice(8, 56);This is great:
- no heap array
- no copy for header/payload
- simple slicing
But only if header and payload cannot escape the method.
That is exactly what these guarantees protect.
Part 4 — Real problems this solves
Example: a WPF desktop app controlling a wafer inspection machine
This is where the topic becomes real.
Imagine the machine software receives:
- image frames
- image metadata
- defect coordinates
- hardware state packets
- result summaries
Some of that data is large. Some arrives frequently. Some must be processed fast.
Parsing image metadata buffers
A camera SDK might give you a byte buffer containing:
- frame id
- timestamp
- exposure
- width/height
- channel format
- inspection flags
A naive design may do this:
- copy chunks into new arrays
- create many temporary objects
- convert slices into separate heap allocations
A more efficient design can use ReadOnlySpan<byte> to parse directly over the source buffer.
That means:
- fewer allocations
- less copying
- better cache behavior
- clearer ownership of temporary parsing state
Working with slices of large arrays
Suppose a single image buffer is large and you want to process:
- one row
- one channel
- one tile
- one ROI
You do not want to copy each region into a new array unless you truly need ownership.
A span-based design lets you say:
- “this is just a view over a subrange”
- “this is temporary”
- “process in place or read from the shared buffer”
That is a very common high-performance pattern.
Processing high-frequency data without allocations
In a hot path, you may have code that runs:
- per image line
- per defect candidate
- per incoming sensor packet
- per protocol message
If that code creates strings, arrays, small wrapper objects, or LINQ iterators on every iteration, you can generate surprising GC pressure.
Using spans, stack-local temporary buffers, and carefully designed structs can remove a large amount of noise from the allocator.
Avoiding temporary object creation in hot paths
The most practical use is not “I want to use fancy language features.”
It is:
“I want this small inner loop to stop allocating.”
That is the right mindset.
Where it does not matter
In the same wafer inspection app, these features usually do not matter much in:
- ViewModels
- screen navigation
- application services
- business workflow orchestration
- configuration screens
- command handlers
- reporting logic
Those layers are dominated by clarity, correctness, maintainability, and coordination logic.
If you bring ref struct thinking into those layers, you are usually solving the wrong problem.
Part 5 — readonly struct in practical usage
A readonly struct means the struct’s instance state cannot be modified after construction.
That gives two important benefits:
- clearer immutability semantics
- better performance behavior in some cases
Immutability guarantees
When you mark a struct as readonly, you are telling the compiler and future readers:
“This value is supposed to behave like an immutable value.”
That is often perfect for small domain values like:
- coordinates
- dimensions
- measurement ranges
- image sizes
- timestamps paired with units
- defect positions
- physical offsets
Example:
public readonly struct PixelPoint
{
public int X { get; }
public int Y { get; }
public PixelPoint(int x, int y)
{
X = x;
Y = y;
}
}That is conceptually a value, not a mutable mini-object.
Avoiding defensive copies
This is an important practical point.
When the compiler sees a non-readonly struct accessed through a readonly context, it may need to create defensive copies to preserve semantics.
That can happen in subtle ways and create extra overhead.
A readonly struct reduces that problem because the compiler knows instance members will not mutate state.
That makes it a better fit for performance-sensitive small value types.
Improving performance for small value types
For small structs used heavily, readonly struct can improve both:
- correctness of intent
- performance predictability
It is especially useful when the value:
- is small
- is passed around often
- represents a stable concept
- should not change after creation
Good examples
In a wafer inspection or data-processing system:
PixelPointImageSizeMicronOffsetExposureSettingThresholdRangeFrameIdDefectScore
These can often be good readonly value types if designed carefully.
When it is beneficial vs unnecessary
Use readonly struct when:
- the type is logically immutable
- the type is small
- it is used frequently
- value semantics are natural
Avoid or rethink when:
- the type is large
- mutation is part of its intended behavior
- the gains are negligible
- a class would better express identity/lifecycle
Also, not every domain value object needs to be a struct. Many should still be classes if identity, references, or richer behavior matter more than allocation cost.
Part 6 — scoped and lifetime safety
scoped is about preventing a reference or ref-like value from escaping beyond a safe lifetime.
You can think of it as:
“This value is borrowed for this scope. You may use it here, but you must not let it escape somewhere longer-lived.”
That is the big idea.
Why this is needed
Once C# introduced powerful ref-like constructs, the language needed more precise lifetime checks.
Sometimes a method wants to accept a span or ref-like parameter and guarantee:
- it will be used only temporarily
- it will not be stored somewhere unsafe
- it will not be returned in a way that outlives its source
scoped helps express that intent.
Conceptual example
Suppose you pass a span into a parser helper. You want the helper to use the span during the call, but not store it anywhere.
scoped lets the compiler enforce that kind of borrowing rule.
That matters because Span<T> is only safe if its lifetime remains tightly controlled.
Why this matters for ref struct and Span<T>
Without lifetime constraints, a method could accidentally:
- store a borrowed span into a field
- return a reference to temporary data
- leak stack-backed memory outside the call chain
scoped helps the compiler reject those cases earlier and more clearly.
You do not need to memorize every syntax form to understand the design.
The important point is:
scopedexists to make stack-safe borrowing more usable without weakening safety.
Part 7 — Limitations and rules
These rules are the heart of the feature.
Do not memorize them like exam trivia. Understand the reason behind them.
Cannot store ref struct in fields of normal classes
Why?
Because class instances live on the heap and may outlive the current method.
If the ref struct contains a view over stack memory or otherwise short-lived memory, storing it in a heap object would let invalid memory survive beyond its safe lifetime.
So the compiler blocks it.
Cannot use in async methods
More precisely: ref-like locals cannot safely live across await.
Why?
Because async methods are transformed into state machines. Locals that need to survive across suspension points may be lifted into heap-allocated state.
That breaks the core lifetime guarantee.
If a span or ref-like value pointed into temporary memory, it would become unsafe once the method yielded.
So C# does not allow that pattern.
Cannot use in iterator methods
Same basic reason.
Iterator methods are also transformed into state machines. Their locals may need to survive between yields, which again implies heap-lifetime behavior.
That is incompatible with stack-only guarantees.
Cannot capture in lambda or closure
Closures typically involve lifted variables stored in a heap-allocated object.
If a ref struct local were captured, it could escape the safe scope of the current stack frame.
So the compiler rejects it.
Cannot box or use as object or interface
Boxing means wrapping a value type in a heap object.
That would immediately violate the design of ref-like stack-only values.
Similarly, using it where interface/object semantics require boxing is not allowed.
Cannot cross async boundaries
This is the operational version of the async rule.
Even if the code looks innocent, once an operation can suspend and resume later, the lifetime becomes much harder to guarantee safely.
So stack-only values must stay entirely within synchronous, scope-bounded usage.
Part 8 — Relationship with Span<T>
Span<T> is the canonical example of why all of this exists.
Why Span<T> is a ref struct
Because Span<T> is a view over memory, not an owning container.
It does not own the data. It just points at some contiguous region and knows the length.
That region may come from:
- an array
- stack memory
- unmanaged memory
- memory owned elsewhere
If Span<T> were allowed normal heap-friendly behavior, it would be too easy to keep a span alive after its source memory became invalid.
So it must be ref-like and stack-restricted.
How it represents a view over memory
Mentally, think of Span<T> as:
“pointer/reference + length + safety rules”
Not literally in unsafe terms for everyday coding, but conceptually that is close enough.
It is not a collection in the normal object-oriented sense. It is a temporary window over memory.
Why it must stay on the stack
Because the whole safety model depends on the compiler being able to constrain its lifetime.
Especially when the source memory might be stack-backed, that restriction is essential.
How slicing works without allocation
This is one of the best parts.
When you slice a span, you are usually not creating a new array. You are creating a new view over the same memory.
Example:
Span<byte> buffer = stackalloc byte[32];
Span<byte> header = buffer[..8];
Span<byte> payload = buffer[8..];No extra arrays were allocated here.
You just created smaller windows into the same memory.
That is exactly why Span<T> is so useful in parsing and processing code.
Part 9 — Practical usage patterns
1. Using Span<T> for slicing
public static int ReadFrameId(ReadOnlySpan<byte> packet)
{
ReadOnlySpan<byte> frameIdBytes = packet.Slice(0, 4);
return BitConverter.ToInt32(frameIdBytes);
}The important idea is not the exact API. The important idea is:
- accept a borrowed view
- work directly over the caller’s buffer
- avoid copying unless necessary
2. Using stackalloc for temporary buffers
Span<byte> temp = stackalloc byte[128];
temp.Clear();This is useful for:
- small temporary workspaces
- formatting/parsing helpers
- short-lived intermediate buffers in hot paths
Good use case:
- parsing metadata packet headers
- converting a few numbers
- temporary scratch space inside a tight synchronous method
Bad use case:
- large buffers
- unknown sizes from uncontrolled input
- general-purpose app-layer code
3. Passing Span<T> to methods
public static void NormalizeRow(Span<byte> row)
{
for (int i = 0; i < row.Length; i++)
{
row[i] = (byte)Math.Min(255, row[i] + 10);
}
}This is nice because:
- the method does not care where memory came from
- caller can pass array slices, stack buffers, or other memory-backed spans
- no new allocation is required
4. Avoiding allocations in parsing code
public static bool TryParseHeader(ReadOnlySpan<byte> data, out int width, out int height)
{
width = 0;
height = 0;
if (data.Length < 8)
return false;
width = BitConverter.ToInt32(data.Slice(0, 4));
height = BitConverter.ToInt32(data.Slice(4, 4));
return true;
}This style is powerful in protocol parsing, file parsing, image metadata handling, and stream decoding.
Realistic pattern in industrial software
In a wafer inspection app, a good architecture is often:
- high-level services deal with domain concepts
- a low-level parsing layer handles raw buffers
- that low-level layer uses spans and maybe stackalloc in hot paths
- parsed results are then converted into normal domain models
That is a healthy separation.
Part 10 — Common mistakes
Mistake 1: trying to store Span<T> in class fields
This usually happens because someone thinks:
“I’ll keep this buffer slice around for later.”
But a span is a borrowed view, not an owned object.
If you need long-lived storage, use:
- an array
Memory<T>- owned buffer abstractions
- an explicit copy
Trying to store a span is usually a sign that ownership and lifetime were not thought through clearly.
Mistake 2: trying to use it in async methods
This happens because modern application code is full of async, and developers naturally want to keep using the same local variables across await.
But spans and ref-like values are scope-bounded, synchronous tools.
If you need memory to survive async flow, you usually need:
- owned heap memory
Memory<T>- an array
- a pooled buffer with explicit lifetime management
Mistake 3: overusing stackalloc
stackalloc feels fast and elegant, so people sometimes start using it everywhere.
That is dangerous because the stack is limited.
Small, predictable temporary buffers are fine.
Large or variable-size allocations on the stack can:
- risk stack overflow
- make code harder to reason about
- create brittle behavior under unusual input sizes
Use it carefully and intentionally.
Mistake 4: misunderstanding lifetime rules
A common mental mistake is thinking only in terms of syntax.
The right question is always:
“Who owns this memory, and how long is it valid?”
If you keep that question in your head, most rules stop feeling arbitrary.
Mistake 5: using ref struct in application-layer code
This usually comes from enthusiasm:
- “This is modern and high-performance”
- “We should optimize everything”
But introducing stack-only constraints into orchestration, business rules, or UI code usually makes the codebase harder to work with for no meaningful gain.
Mistake 6: adding complexity without real performance benefit
This is the most senior mistake because it can come from smart people.
They know these tools, so they want to use them.
But the right engineering question is:
“Is allocation here actually a measured bottleneck?”
If not, do not pay readability and flexibility costs without evidence.
Part 11 — Performance and trade-offs
Allocation-free vs complexity
Yes, stack-only patterns can remove allocations.
But they also introduce:
- lifetime restrictions
- async limitations
- more careful API design
- steeper learning curve
So the trade is not “free speed.”
The trade is:
- less garbage
- tighter control
- more complexity
Stack usage limits
The stack is fast, but not infinite.
Small temporary buffers are great.
Large stack allocations are risky.
That means stack-only tools are best for:
- small temporary work
- local views over existing memory
- hot-path processing
- parser-style code
Not for giant working sets.
Readability vs performance
A span-based parser can be excellent.
A span-heavy business service can be awful.
The best performance code is often isolated and intentional, not spread across the whole codebase.
Safety vs flexibility
Stack-only types are safe because they are restricted.
That also makes them less flexible.
That is not a defect. That is the deal.
When benefits are real vs negligible
Benefits are real when:
- allocations are frequent
- code is on a hot path
- buffers are large
- copies are expensive
- the system is latency-sensitive
- profiling shows allocation pressure
Benefits are negligible when:
- code runs rarely
- data sizes are small
- readability matters more
- GC is not the bottleneck
- overall system cost is elsewhere
Part 12 — When to use vs avoid
Use when:
- you are working in hot paths
- you are processing large buffers
- you are writing performance-critical libraries
- you are parsing text or binary data frequently
- you want slicing without copying
- you are removing measured allocation pressure in tight loops
Avoid when:
- you are writing business/application logic
- you are in UI/ViewModel code
- performance is not the bottleneck
- async flow dominates the design
- the team is unfamiliar and the value is small
- the code becomes harder to maintain than the gain justifies
A very good rule is:
Use these tools in the lowest-level code that truly benefits from them, and keep higher-level code normal.
That is how mature systems stay maintainable.
Part 13 — Senior engineer mental model
An experienced engineer does not start with ref struct.
They start with questions like:
- Where is the hot path?
- What is allocating?
- Who owns this memory?
- How long does this data need to live?
- Are we copying because we need ownership, or just because the API forced us to?
- Can we isolate low-level optimization to one layer?
That is the right mindset.
How experienced engineers think about memory lifetimes
They think in lifetimes first:
- temporary borrowed view
- owned buffer
- immutable value
- long-lived shared object
- async-crossing data
- stack-local scratch space
Once you think in lifetimes, the type choice becomes clearer.
How to decide when stack-only types are worth it
Usually:
- identify a real hot path
- measure allocations and throughput
- confirm copying or temporary objects are part of the problem
- optimize the narrow low-level section
- keep the rest of the system simple
That is disciplined performance engineering.
How to isolate low-level performance code from high-level logic
A strong design often looks like this:
Low-level layer parsing buffers, slicing spans, using
stackalloc, tiny readonly structsBoundary layer convert raw data into stable domain/application models
High-level layer workflows, UI, orchestration, commands, persistence, business rules
That separation is extremely important.
You do not want span rules leaking into every part of the codebase.
How to avoid leaking complexity into the rest of the system
Keep the complex performance code:
- small
- local
- well-tested
- documented
- benchmarked
- hidden behind clean APIs
The worst outcome is a codebase where every layer becomes “kind of low-level” but nobody fully understands lifetimes anymore.
How to keep performance optimizations controlled and maintainable
Good teams treat these tools like scalpels, not lifestyle choices.
Use them where:
- data volume is real
- allocation pressure is measured
- latency matters
- APIs benefit from slice-based access
Do not use them just because they are impressive.
That is the senior-level judgment interviewers usually want to hear.
Final practical summary
ref struct, Span<T>, scoped, and related stack-only patterns exist to let C# write fast, low-allocation, memory-oriented code safely.
They matter because they let you:
- work with temporary memory views
- avoid unnecessary allocations
- avoid unnecessary copying
- keep hot paths lean
- express lifetime constraints in the type system
They are valuable in:
- parsers
- binary protocols
- image/data pipelines
- tight processing loops
- performance-critical libraries
They are usually not valuable in:
- ViewModels
- business workflows
- orchestration code
- most application services
readonly struct is the calmer sibling in this story. It is not stack-only, but it helps express immutable value semantics and can improve performance for small frequently used value types.
scoped helps the compiler enforce borrowing rules so these powerful memory abstractions stay safe.
The most important principle is simple:
Use stack-only features when lifetime control and allocation reduction matter in measured hot paths. Keep them contained. Keep the rest of the system boring.
That is how real production systems stay both fast and maintainable.
If you want, I can do the next step as an interview-style follow-up: 10 realistic interview questions and strong senior-level answers on ref struct, readonly struct, scoped, and Span<T>.