Using the Visual Studio Memory window to read raw bytes for buffers, interop structs, encodings and unsafe code, and contrasting it with heap snapshots, allocation tracking and dotnet-gcdump for memory leaks.
The Memory window shows the raw bytes at an address in the paused process, as hex with a text column. In .NET I use it when the bytes themselves are the question: a binary protocol parsed wrongly because of endianness, a UTF-8 versus UTF-16 encoding bug, a struct layout passed to native code, or a buffer in unsafe code. I type a pointer or address expression to jump there. It is not how you find memory leaks. For those I use the Memory Usage tool's heap snapshots and diffs, .NET Object Allocation Tracking, or dotnet-gcdump and dotnet-dump in production.
"The Memory window shows the raw bytes at an address in the paused process, as hex or as 1- to 8-byte integers or floating point, with a text column alongside. In Visual Studio I turn on address-level debugging, open Debug, Windows, Memory, and type an address or an expression that evaluates to one, such as a pointer in unsafe or
fixedcode.I use it when the bytes themselves are the question: a binary protocol misread because of endianness, a UTF-8 versus UTF-16 encoding bug, a struct layout with padding before it goes to native code, or a
stackallocbuffer in unsafe code. Two cautions: the GC can move objects, so a managed address is only stable while it's pinned or within a single break, and managed object layout, the header and method table pointer, is a runtime implementation detail, not a contract. Memory also holds secrets and personal data, so I treat screenshots as sensitive.The mistake I see is using it to hunt a memory leak, which raw bytes can't answer. For growth I use the Memory Usage tool's heap snapshots and diffs, or .NET Object Allocation Tracking in the profiler to see which code allocates, and in production
dotnet-counters,dotnet-gcdumpor a fulldotnet-dump.It comes down to choosing the right level of abstraction: raw bytes settle encoding and layout questions instantly, while leaks need object-level tools."
In Visual Studio, turn on Tools → Options → Debugging → General → Enable address-level debugging, then while paused open Debug → Windows → Memory → Memory 1 (up to four windows). Type an address or an expression that evaluates to an address, and the window shows the bytes from there, with options for 1-, 2-, 4- or 8-byte grouping, signed or unsigned display, and a text column. Changed bytes are highlighted after each step. You can also edit bytes in place.
00 00 01 2C = 300) when your parser read it as little-endian.C3 A9 for é) or UTF-16 (E9 00), or whether a byte order mark is present.[StructLayout(LayoutKind.Sequential)] struct before it goes to a native library.Span<T> and stack buffers: verify what stackalloc or a fixed block actually contains.Most "memory problems" in .NET services are leaks or excessive allocation, which raw bytes cannot answer. Use:
| Question | Tool |
|---|---|
| What is growing between two points in time? | Diagnostic Tools / Performance Profiler Memory Usage: take heap snapshots and diff them |
| Which code allocates the most? | .NET Object Allocation Tracking in the Performance Profiler |
| Who keeps an object alive? | Heap snapshot paths to root, or gcroot in dotnet-dump |
| Is the GC under pressure in production? | dotnet-counters (GC heap size, gen 2 collections, allocation rate) |
| Heap contents from production cheaply | dotnet-gcdump collect -p <pid> |
A telemetry ingestion service in Azure decoded device packets and occasionally recorded battery levels of about 1.5 billion percent. A breakpoint after the read, with the Memory window pointed at the packet buffer, showed the field bytes were 00 00 00 5A: big-endian 90. The parser used BitConverter.ToInt32, which reads in machine (little-endian) order and turned those bytes into 1,509,949,440. Switching to BinaryPrimitives.ReadInt32BigEndian fixed it.
The interviewer is probing depth: do you know what this window is for, and do you avoid using it for the wrong job?
Green flags
Red flags
The Memory window reads bytes from the paused process's virtual address space and displays them. It doesn't know about types, objects or fields. You give it an address, and it shows what is there.
Memory 1 Address: p (an expression that evaluates to an address)
-----------------------------------------------------------------------
0x000001A23F50 00 00 00 5a 00 00 01 2c 00 00 00 00 00 00 00 00 ...Z...,........
0x000001A23F60 ...
^ address ^ bytes (1, 2, 4 or 8 per column) ^ text column
Settings you can change by right-clicking:
| Option | Choices |
|---|---|
| Grouping | 1, 2, 4 or 8-byte integers, or 32/64-bit floating point |
| Format | Hexadecimal, signed or unsigned decimal |
| Text column | ANSI, Unicode (UTF-16) or none |
| Columns | Fixed width or auto |
| Reevaluate automatically | Follow an expression such as a pointer variable as it changes |
Bytes that changed since the last break are highlighted. You can also type over bytes to change memory in place, with all the risks of changing state in the debugger.
0x000001A23F50) or an expression that evaluates to one: a pointer variable p, &value for a local in unsafe code, or a nint holding an address.VS Code and Rider have more limited raw memory views. For .NET, Visual Studio has the fullest Memory window.
On 64-bit .NET, a byte[] looks like this. The object reference points at the method table pointer:
address (relative) bytes meaning
-8 [ object header, 8 bytes ] sync block index / hash code bits
0 <-- reference [ method table pointer, 8 ] identifies the type: byte[]
+8 [ length 4 + padding 4 ] packet.Length
+16 00 00 00 5A 00 00 01 2C packet[0], packet[1], ...
|---- int32 ----|
big-endian: 90
little-endian: 1,509,949,440 <-- the bug
This layout has been stable across modern .NET, including .NET 10, but it is a runtime implementation detail, not a contract. Read it to understand what you see; never write code that depends on it.
The same four bytes mean different numbers depending on byte order:
| Bytes in memory | Read big-endian | Read little-endian |
|---|---|---|
00 00 00 5A |
90 | 1,509,949,440 |
00 00 01 2C |
300 | 738,263,040 |
2C 01 00 00 |
738,263,040 | 300 |
| API | Byte order |
|---|---|
BitConverter.ToInt32 |
The machine's order (BitConverter.IsLittleEndian), little-endian on x64 and Arm64 |
BinaryPrimitives.ReadInt32BigEndian / LittleEndian |
Explicit |
BinaryReader.ReadInt32 |
Always little-endian |
IPAddress.NetworkToHostOrder |
Converts big-endian network order |
Network protocols and many device formats are big-endian, so code that uses BitConverter for wire data is a common bug.
The text "Café" in memory:
| Encoding | Bytes | How to recognise it |
|---|---|---|
UTF-16 (a .NET string in memory) |
43 00 61 00 66 00 E9 00 |
A 00 after every ASCII character |
UTF-8 (Encoding.UTF8.GetBytes) |
43 61 66 C3 A9 |
é becomes two bytes C3 A9 |
| UTF-8 with BOM | EF BB BF 43 61 66 C3 A9 |
Starts with EF BB BF |
| Windows-1252 (legacy) | 43 61 66 E9 |
é is one byte E9 |
If text shows as Café, UTF-8 bytes were decoded as Windows-1252 or Latin-1. The Memory window shows which bytes are really there, which settles the argument immediately.
[StructLayout(LayoutKind.Sequential)]
struct Header { public byte Version; public int Length; public short Flags; }
offset 0: Version (1 byte)
offset 1: padding (3 bytes) <-- inserted so Length is 4-byte aligned
offset 4: Length (4 bytes)
offset 8: Flags (2 bytes)
offset 10: padding (2 bytes)
size = 12, not 7
If the native side expects a packed 7-byte struct, use [StructLayout(LayoutKind.Sequential, Pack = 1)]. The Memory window on &header shows the padding bytes directly. Unsafe.SizeOf<Header>() and Marshal.OffsetOf confirm it in code.
The garbage collector compacts the heap, so a managed object can move whenever the process runs. While you are stopped, addresses are stable; after you continue they may not be.
| Memory | Stable address? |
|---|---|
| A managed object on the GC heap | Only while paused, or while pinned |
Inside a fixed block |
Yes, until the block ends |
A pinned GCHandle, or the Pinned Object Heap (GC.AllocateArray(pinned: true)) |
Yes |
stackalloc |
Yes, until the method returns |
NativeMemory.Alloc / Marshal.AllocHGlobal |
Yes, until you free it |
That is why the code example uses a fixed block.
| Question | Right tool | Why not the Memory window |
|---|---|---|
| What bytes are in this buffer? | Memory window | (It is the right tool) |
| What is growing over time? | Memory Usage tool: heap snapshots and diff | Bytes don't tell you object counts |
| Which code allocates the most? | .NET Object Allocation Tracking | It shows a moment, not allocation call stacks |
| Who keeps this object alive? | Snapshot "paths to root", or gcroot in dotnet-dump |
It can't follow references |
| Is the GC under pressure in production? | dotnet-counters (heap size, gen 2 count, allocation rate) |
Needs a paused process |
| Heap contents from production | dotnet-gcdump (small, types and references only) or dotnet-dump (full) |
You can't attach and pause production |
The tools combine well: find the suspicious buffer with a snapshot or a dump, then look at its bytes in the Memory window (or with dumpmemory/db commands in a dump).
Memory holds everything the process knows: decrypted payloads, tokens, keys and personal information. Treat screenshots of the Memory window and any dump file as sensitive data, debug with test data where you can, and never paste raw memory into a public issue.
The Memory window is an IDE feature and works the same for .NET 11 previews. Object layout details may change between runtime versions, which is one more reason never to depend on them in code.
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> and paste the code example.fixed block, and type p into Memory 1.string s = "Café"; and byte[] u = Encoding.UTF8.GetBytes(s);, then view both. For the string, use fixed (char* c = s) and enter c.Header struct from section 6, take &header in unsafe code, and find the padding bytes.PacketDemo.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
Program.cs: break inside the fixed block and enter p in the Memory window:
using System.Buffers.Binary;
byte[] packet = [0x00, 0x00, 0x00, 0x5A, 0x00, 0x00, 0x01, 0x2C];
unsafe
{
fixed (byte* p = packet)
{
// Breakpoint here: Memory 1 → address expression `p`
Console.WriteLine($"Buffer at 0x{(nint)p:X}");
}
}
int wrong = BitConverter.ToInt32(packet, 0); // machine endianness
int battery = BinaryPrimitives.ReadInt32BigEndian(packet.AsSpan(0, 4));
int voltage = BinaryPrimitives.ReadInt32BigEndian(packet.AsSpan(4, 4));
Console.WriteLine($"BitConverter: {wrong}, big-endian: {battery}, voltage: {voltage}");
Production heap investigation instead of the Memory window:
dotnet tool install --global dotnet-gcdump
dotnet-gcdump collect -p 12345 -o before.gcdump
# ...wait for growth...
dotnet-gcdump collect -p 12345 -o after.gcdump
# open both in Visual Studio or PerfView and compare object counts and sizes
Mistake: using the Memory window to hunt a leak or a slowdown.
Raw bytes can't tell you which objects are growing, who holds them or which code allocates them.
Instead: use heap snapshot diffs and .NET Object Allocation Tracking locally, and dotnet-counters and dotnet-gcdump in production. Use the Memory window afterwards to inspect a specific suspicious buffer.
Mistake: not finding the window. It is hidden until address-level debugging is enabled, and only available while paused. Instead: turn on Tools > Options > Debugging > General > Enable address-level debugging, pause, then open Debug > Windows > Memory.
Mistake: assuming BitConverter matches the wire format.
It uses the machine's byte order, little-endian on x64 and Arm64, while most network and device formats are big-endian.
Instead: use BinaryPrimitives.ReadInt32BigEndian (or LittleEndian) so the byte order is explicit, and test with captured real packets.
Mistake: misreading UTF-16 as corrupted text.
A .NET string is UTF-16 in memory, so every ASCII character is followed by 00.
Instead: switch the text column to Unicode, and remember UTF-8 only exists after Encoding.UTF8.GetBytes.
Mistake: expecting a managed object's address to stay the same after continuing.
The GC compacts the heap, so the address you noted may now hold a different object.
Instead: re-enter the expression (or enable reevaluate automatically) after each break, and use fixed, pinned handles or native memory when you need a stable address.
Mistake: relying on object layout in code.
The header and method table pointer layout is an implementation detail that can change between runtime versions.
Instead: use Unsafe, MemoryMarshal, StructLayout and Marshal.OffsetOf for anything your code depends on.
Mistake: assuming struct size equals the sum of its fields.
Padding aligns fields, so a struct sent to native code can be bigger than expected and fields can sit at different offsets.
Instead: check with Unsafe.SizeOf<T>() and the Memory window, and set Pack or explicit FieldOffset to match the native definition.
Mistake: editing bytes in the Memory window and trusting the result. Writing memory directly can break object headers or invariants, and the program's behaviour afterwards isn't real. Instead: treat edits as a one-off experiment and restart the session afterwards.
Mistake: sharing memory screenshots or dumps. They can contain customer data, tokens and keys. Instead: use test data, store dumps encrypted with restricted access, and redact before sharing anything.
The senior skill is choosing the right level of abstraction. Raw bytes answer encoding, endianness and layout questions definitively and quickly, and seniors reach for the Memory window there instead of guessing with log lines. For memory growth they go straight to snapshot diffs and allocation profiles, and in production to dotnet-counters and dotnet-gcdump, because the question is "which objects and who holds them", not "what bytes". They also design binary parsing to be explicit (BinaryPrimitives, SequenceReader<byte>) with tests built from captured real packets, so the Memory window is rarely needed twice for the same bug.
When I can't attach a debugger, I take a dump: a snapshot of the process's memory, threads and stacks that I analyse offline. For a hang or a leak, dotnet-dump collect -p <pid> does it in seconds. For crashes, I enable automatic crash dumps with the DOTNET_DbgEnableMiniDump environment variables. On App Service I use Diagnose and solve problems, and in Kubernetes dotnet-monitor. Then I analyse with dotnet-dump analyze: clrstack -all and syncblk for hangs, dumpheap -stat and gcroot for leaks, pe for exceptions. Or I open the dump in Visual Studio and use the Threads and Parallel Stacks windows. Dumps contain customer data, so they're handled as sensitive.
First I measure: Application Insights tells me which endpoint is slow and at what percentile, and whether time goes into dependencies like SQL or HTTP. Then I reproduce it on a Release build and run the Visual Studio Performance Profiler (Alt+F2): CPU Usage for hot paths, Database for query count and duration, .NET Object Allocation Tracking for GC pressure, and .NET Async for slow awaits. In an environment where I can't use Visual Studio, dotnet-counters shows live metrics like thread pool queue length and GC, and dotnet-trace captures CPU samples. I fix the biggest cause, often an N+1 query, re-measure against the baseline, and add a load test so it doesn't regress.
How to tell it (STAR)
**Situation:** Our [telemetry service] was recording battery levels in the billions for one firmware family. **Task:** I needed to find out why without any exceptions to go on. **Action:** I replayed a captured packet in a small harness, looked at the buffer in the Memory window and saw the fields were big-endian while our parser assumed little-endian. I switched to `BinaryPrimitives`, reprocessed the affected messages and added tests from real packets. **Result:** The bad readings stopped and [N days] of data were corrected.
Last updated 1 Nov 2026.