# Microsoft.IO.RecyclableMemoryStream: pooling the buffers behind MemoryStream

> An MIT-licensed .NET library that swaps MemoryStream for a pooled implementation to keep large allocations on the gen 2 heap and out of gen 0 churn. Its README still documents the build targets of version 2, while the newest release dropped three of those targets and is two years old.

**microsoft/Microsoft.IO.RecyclableMemoryStream** — A library to provide pooling for .NET MemoryStream objects to improve application performance. 

- Repository: https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream
- Stars: 2,150 · Forks: 213
- Language: C#
- License: MIT
- Published: 2026-10-08 · Updated: 2026-10-08 · Language: en
- Canonical page: https://hysenlabs.com/projects/microsoft-microsoft-io-recyclablememorystream

## Install the package and stop allocating short-lived buffers

The package comes from NuGet under the name of the namespace.

```powershell
Install-Package Microsoft.IO.RecyclableMemoryStream
```

The claim in the README is a garbage collection claim, not a throughput claim. A conventional MemoryStream allocates a byte array per instance, and those arrays are collected in the normal way. This library keeps the larger buffers used for streams in the generation 2 heap and leaves them there, so full collections happen less often and the application spends less time paused. Buffers above 85,000 bytes land on the large object heap, which the collector touches even less often, so the library steers toward that threshold deliberately.

The listed benefits read like a list of things that go wrong with plain streams: eliminating large object heap allocations through pooled buffers, incurring far fewer gen 2 collections, avoiding memory fragmentation, and avoiding memory leaks by bounding the pool. The last one is the design in a sentence. An unbounded pool is just a leak with better manners, so the manager has a ceiling you configure.

What you give up is concurrency, and the README says so directly. The manager class is thread-safe; the streams themselves are not, exactly as with the original type. That is the correct division, since a shared buffer is what is being protected, not per-stream state.

## Pooling buffers rather than streams, which is the whole idea

The most important design line in the features list is easy to skim past: rather than pooling the streams themselves, the underlying buffers are pooled.

That single choice produces everything else. Because a stream is an ordinary object with an ordinary Dispose method, you keep the familiar using pattern and the familiar lifetime rules. Because the buffer comes from a pool rather than an allocation, disposing hands it back where the manager can find it again. And because the pool knows what it handed out, it can notice when a stream is used after disposal, which is exactly the class of bug that a pool turns a silent corruption into an exception.

The debug story is built on that same mechanism. Each stream can carry a tag string used in logging, so a leak report points at the call site that created the stream rather than at an anonymous allocation. There is also a debug feature that records the call stack of the stream allocation, which is how you trace which pool leak came from where. Metrics and events are there as well, so the effect of the change on your system is observable rather than a matter of opinion.

The type surface is current .NET rather than historical. The stream implements the buffer-write interface, and the library supports the newer span and memory types, including read-only sequences. That matters for adoption, because code that has already moved to those types can pass a stream into it without an adapter.

## Two pools, and why GetBuffer forces a different one

The manager keeps two separate pools, and the split is driven by one method.

The small pool holds buffers of a configurable size and serves ordinary read and write traffic. A stream starts on one small buffer and chains more as capacity grows, which is how the library supports streams far larger than any single array. The chaining is abstracted away so callers see one stream.

The large pool is used only when a caller genuinely needs one contiguous buffer, and the method that forces this is GetBuffer. That call is bounded by .NET array size limits, and the README is explicit that a stream longer than that should not use it. There is also a constructor overload that takes a flag asking for contiguous storage from the start, in which case the stream is assigned a single large buffer immediately rather than being converted later.

The large pool comes in two shapes. Linear is the default: you give a multiple and a maximum, and you get an array of slots holding one multiple, two multiples, three multiples, and so on up to the maximum, so a multiple of 1 MB with an 8 MB maximum gives eight slots. Exponential doubles instead, so a multiple of 256 KB with the same 8 MB maximum gives six slots from 256 KB up to 8 MB.

The README gives the guidance: unpredictable large buffer sizes suit the linear shape, while many streams that stay short suit the exponential one because the unused slots cost almost nothing. Slots are allocated on demand the first time something of that size is requested.

## Two properties decide whether your pool is a feature or a leak

Buffers return to the manager on dispose, and then the manager decides whether to keep them, using two properties.

```csharp
MaximumFreeSmallPoolBytes
MaximumFreeLargePoolBytes
```

Anything the manager declines to keep is left for the garbage collector, and these two values are what determine how large the pool can grow. The README then says the thing that should be quoted in every article about pooling: if you set them to 0, you get unbounded pool growth, which is essentially indistinguishable from a memory leak.

That is a blunt admission and it is the right one. The pattern is safe by default and unsafe by misconfiguration, and the failure mode is not a crash but a slow drift in resident memory that looks like the application's own retention problem. If you adopt this library, setting those two numbers is the first thing to do, and deciding them requires knowing your steady-state working set rather than your peak.

There are also flexible limits on the pooling algorithm itself, and a maximum free pool size described as a way to absorb spikes in usage without holding too much memory afterwards. The distinction between a spike allowance and a steady-state ceiling is the difference between a pool that absorbs a burst and one that never gives the memory back, and the two properties above are what draw that line.

One failure mode is documented in the release history. Version 3.0.1, the most recent release, fixed an infinite loop when calling the buffer accessor on a stream longer than one gigabyte while the exponential large buffer option was in use. If you use the exponential pool with very large streams, that is the bug to know about.

## The README describes version 2 while version 3 is what ships

This is the most useful thing to notice about the project, because it affects what you will actually build against.

The Build Targets section says that at least MSBuild 16.8 is required, obtained with Visual Studio 2019, and then lists the targets in v2.0 as net462, netstandard2.0, netstandard2.1 and netcoreapp2.1, with net40, net45, net46 and netstandard1.4 described as deprecated. It adds that starting with v2.1 a net5.0 target was added.

The 3.0.0 release notes say something different and later. Explicit targets for net462, netcoreapp2.1 and net5.0 were removed, and the supported targets are netstandard2.0, netstandard2.1 and net6.0, with a few net6.0-specific optimisations mentioned. The same release moved every manager configuration setting into a new Options class, removed the int-based constructors and GetStream overloads where long-based methods exist, removed everything marked obsolete, enabled nullable annotations and warnings, and changed all GetStream overloads to return the recyclable stream type rather than MemoryStream.

So the README's framework matrix describes a line two majors behind, and the release notes describe a library with a different configuration surface, different return types and nullable reference types enabled. Both documents are correct about their own version. The practical consequence is that anyone porting from an older version has real work to do: constructors have moved, return types have narrowed, and code that was not annotated for nullability will now produce warnings.

The testing instructions have the same drift. The README says a minimum of .NET 5.0 is needed to run the unit tests, which is a version the current release no longer targets. The practical detail worth keeping from that section is not the version but the architecture warning: set the default processor architecture for tests to x64, or the giant allocation test will fail.

## Two years of commits after the last release

The last release is 3.0.1, published on 2024-06-11, and it contains a single bug fix. The last push to the repository was on 2026-09-24. That gap is worth naming plainly rather than reading as either abandonment or a pending release: two years of work on master with nothing tagged, and a README still describing the version 2 target matrix.

For a library at this level of maturity the risk is not that the design is unstable. It is that the documentation you are reading is pinned to a past release, so anything you learn from the README has to be checked against the version you actually install. CHANGES.md exists in the tree and is linked from the README as the place to read the history, which makes it the document to trust when the two disagree.

The repository itself is well organised for a small library. There is a solution file, a source directory, a unit test project, a separate benchmark project, an examples project containing the code shown in the README, a devcontainer definition, a global.json pinning the SDK, a NuGet configuration, and a security policy alongside a support file. A strong-named key file is present, which matters for a library that other assemblies may reference.

Benchmarks are published as a documentation site rather than in the repository, and the README links to a benchmark results page under the project's GitHub Pages site. Treat those numbers as the project's own measurements on the project's own hardware: they will tell you the shape of the improvement across buffer sizes, and the pool comparison diagram in the README is the clearest single illustration of why the two large pool shapes behave differently.

## Conclusion

This library earns its place in a service that serialises or buffers data in the hot path and can measure the effect, because the design decision of pooling buffers rather than streams is what makes disposal observable and misuse detectable. It is the wrong tool if your allocations are already small, if you need one contiguous buffer unconditionally, or if you need a target the current release no longer builds. Before adopting, read CHANGES.md rather than the README's build target section, because the README still lists framework targets that release 3.0.0 removed, and confirm the version you install actually ships. The two properties to set first are MaximumFreeSmallPoolBytes and MaximumFreeLargePoolBytes, since leaving them unbounded is the configuration the README itself describes as indistinguishable from a memory leak.

## FAQ

### What does Microsoft.IO.RecyclableMemoryStream replace?

It is intended as a drop-in replacement for System.IO.MemoryStream in performance-sensitive code, with semantics close to the original. It implements the buffer write interface and supports span, read-only span, read-only sequence and memory types, so newer code paths work without an adapter.

### How do I stop RecyclableMemoryStream from leaking memory?

Set MaximumFreeSmallPoolBytes and MaximumFreeLargePoolBytes on the manager. They control how much the pool retains after buffers are returned on dispose, and the README states that setting them to zero produces unbounded pool growth that is essentially indistinguishable from a memory leak.

### Why is my stream not using the large buffer pool?

The large pool is only used when a contiguous buffer is required, which most often means a call to GetBuffer. Ordinary read and write traffic uses the small pool, with buffers chained together and abstracted into one stream. Requesting a stream with asContiguousBuffer set assigns a single large buffer from the start.

### Which .NET versions does the current RecyclableMemoryStream support?

The 3.0.0 release notes list netstandard2.0, netstandard2.1 and net6.0, with explicit targets for net462, netcoreapp2.1 and net5.0 removed. The README's build target section still describes the version 2 matrix including net462 and netcoreapp2.1, so check the release notes rather than the README.

### Is RecyclableMemoryStream thread safe?

The manager class is thread-safe, but the streams themselves are not, in the same way ordinary MemoryStream instances are not. The buffers are what the manager protects, and a stream over a shared buffer still needs your own synchronisation.

## Sources

- [Issues](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream/issues)
- [License: MIT](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream/blob/master/LICENSE)
- [microsoft/Microsoft.IO.RecyclableMemoryStream on GitHub](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream)
- [README](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream/blob/master/README.md)
- [Releases](https://github.com/microsoft/Microsoft.IO.RecyclableMemoryStream/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/microsoft-microsoft-io-recyclablememorystream
