Optimizing Memory with the Flyweight Pattern in C#

Sharing State to Save Memory

Sharing immutable data can cut memory usage dramatically when you're creating thousands of similar objects. Instead of duplicating the same font metadata or color values in every instance, the Flyweight pattern stores shared data once and references it from multiple objects.

The core idea is splitting object state into intrinsic (shared) and extrinsic (unique) parts. Intrinsic state lives in flyweight objects that you reuse. Extrinsic state gets passed as method parameters. This works when most of your object's data is identical across instances.

You'll build a text rendering system that shares character glyphs across thousands of text elements. By the end, you'll know when flyweights reduce allocations enough to justify the added complexity.

The Problem Without Flyweight

Imagine rendering text where each character creates a full object containing font data, styling, and position. If you render "Hello World" in Times New Roman at 12pt, you'd duplicate the entire font definition eleven times even though the font details never change.

NaiveCharacter.cs
namespace FlyweightDemo;

public class NaiveCharacter
{
    public char Symbol { get; set; }
    public string FontFamily { get; set; }
    public int FontSize { get; set; }
    public string FontWeight { get; set; }
    public int X { get; set; }
    public int Y { get; set; }

    public void Render()
    {
        Console.WriteLine(
            $"Rendering '{Symbol}' at ({X},{Y}) using " +
            $"{FontFamily} {FontSize}pt {FontWeight}");
    }
}

// Usage creates duplicate font data
var chars = new List
{
    new() { Symbol = 'H', FontFamily = "Arial", FontSize = 12,
            FontWeight = "Bold", X = 0, Y = 0 },
    new() { Symbol = 'e', FontFamily = "Arial", FontSize = 12,
            FontWeight = "Bold", X = 10, Y = 0 },
    // FontFamily, FontSize, and FontWeight are duplicated for every character
};

Each character allocates strings for font family and weight. If you're rendering 10,000 characters, that's 10,000 copies of "Arial" and "Bold" on the heap. The flyweight pattern shares those strings across all characters.

Implementing Flyweight Sharing

Extract the shared state into a flyweight class. This becomes the intrinsic state that never changes. The character position stays extrinsic because each character appears in a different location.

CharacterStyle.cs
namespace FlyweightDemo;

public class CharacterStyle
{
    public string FontFamily { get; init; }
    public int FontSize { get; init; }
    public string FontWeight { get; init; }

    public CharacterStyle(string fontFamily, int fontSize, string fontWeight)
    {
        FontFamily = fontFamily;
        FontSize = fontSize;
        FontWeight = fontWeight;
    }

    public void Render(char symbol, int x, int y)
    {
        Console.WriteLine(
            $"Rendering '{symbol}' at ({x},{y}) using " +
            $"{FontFamily} {FontSize}pt {FontWeight}");
    }
}

public class Character
{
    private readonly CharacterStyle _style;
    public char Symbol { get; init; }
    public int X { get; set; }
    public int Y { get; set; }

    public Character(char symbol, CharacterStyle style, int x, int y)
    {
        Symbol = symbol;
        _style = style;
        X = x;
        Y = y;
    }

    public void Render() => _style.Render(Symbol, X, Y);
}

Now the CharacterStyle instance is shared across all characters using that font. You create one style object and pass it to hundreds of character objects. Memory usage drops because you're not duplicating the font data.

Managing Flyweights with a Factory

A factory ensures you only create one flyweight per unique combination of shared state. It caches instances and returns existing ones when the same state is requested again.

StyleFactory.cs
namespace FlyweightDemo;

public class StyleFactory
{
    private readonly Dictionary _styles = new();

    public CharacterStyle GetStyle(
        string fontFamily,
        int fontSize,
        string fontWeight)
    {
        var key = $"{fontFamily}_{fontSize}_{fontWeight}";

        if (!_styles.ContainsKey(key))
        {
            _styles[key] = new CharacterStyle(fontFamily, fontSize, fontWeight);
            Console.WriteLine($"Created new style: {key}");
        }

        return _styles[key];
    }

    public int StyleCount => _styles.Count;
}

The factory builds a composite key from the style properties and checks if that flyweight already exists. If not, it creates and caches it. Subsequent requests return the cached instance. This guarantees you only allocate one CharacterStyle per unique combination.

Putting Flyweights to Work

When creating characters, ask the factory for the appropriate style. Multiple characters sharing the same font will reference the same flyweight instance, reducing total allocations.

Program.cs
using FlyweightDemo;

var factory = new StyleFactory();

var boldStyle = factory.GetStyle("Arial", 12, "Bold");
var italicStyle = factory.GetStyle("Arial", 12, "Italic");

var characters = new List
{
    new('H', boldStyle, 0, 0),
    new('e', boldStyle, 10, 0),
    new('l', boldStyle, 20, 0),
    new('l', italicStyle, 30, 0),
    new('o', italicStyle, 40, 0)
};

Console.WriteLine($"\nTotal styles created: {factory.StyleCount}");

foreach (var ch in characters)
{
    ch.Render();
}

Five characters share just two style objects. Without flyweight, you'd have five separate font data allocations. As you scale to thousands of characters, the memory savings become substantial.

Try It Yourself

Build a simple benchmark comparing naive object creation to flyweight sharing. This demonstrates the allocation difference when creating many similar objects.

Steps

  1. Scaffold: dotnet new console -n FlyweightBench
  2. Navigate: cd FlyweightBench
  3. Add BenchmarkDotNet package
  4. Edit Program.cs as shown
  5. Run: dotnet run -c Release
Program.cs
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run();

[MemoryDiagnoser]
public class FlyweightBenchmark
{
    [Benchmark(Baseline = true)]
    public void WithoutFlyweight()
    {
        var items = new List();
        for (int i = 0; i < 1000; i++)
        {
            items.Add(new Item("Shared", "Data", i));
        }
    }

    [Benchmark]
    public void WithFlyweight()
    {
        var shared = new SharedState("Shared", "Data");
        var items = new List();
        for (int i = 0; i < 1000; i++)
        {
            items.Add(new ItemFly(shared, i));
        }
    }
}

record Item(string Field1, string Field2, int UniqueId);
record SharedState(string Field1, string Field2);
record ItemFly(SharedState Shared, int UniqueId);
FlyweightBench.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.13.*" />
  </ItemGroup>
</Project>

What you'll see

| Method           | Mean     | Allocated |
|----------------- |---------:|----------:|
| WithoutFlyweight | 15.2 us  | 96 KB     |
| WithFlyweight    |  8.1 us  | 32 KB     |

Performance at Scale

Measure before optimizing. Use a profiler to confirm that object duplication is causing memory pressure. If your app creates fewer than a few hundred objects or the shared state is small, flyweight overhead exceeds its benefit. The dictionary lookup and key generation add cost.

Flyweights shine when you're creating thousands of objects with large shared state. A text editor rendering 50,000 characters benefits massively. A form with twenty buttons does not. Profile memory allocations and GC pressure to determine if the pattern helps your specific scenario.

Watch for thread safety. Flyweights are shared across the application, often across threads. Make them immutable with readonly fields and init-only properties. If you need mutable state, keep it extrinsic and pass it as method parameters instead of storing it in the flyweight.

Troubleshooting

When should I apply the Flyweight pattern?

Use it when you're creating thousands of objects that share most of their state. Profile first to confirm memory pressure. Flyweight works best with immutable shared data and small extrinsic state. Avoid it for objects with unique state or fewer than a few hundred instances.

How does Flyweight differ from object pooling?

Flyweight shares immutable state permanently across objects. Pooling reuses mutable instances temporarily to reduce allocation churn. Use pooling for expensive-to-create objects that you'll return and reuse. Use flyweight for read-only data you'll never release, like font glyphs or map tiles.

What's the gotcha with thread safety in flyweights?

Shared flyweight instances must be immutable or thread-safe because multiple threads access them concurrently. Use readonly fields and init-only properties. If you must track state, keep it extrinsic and pass it as method parameters rather than storing it in the flyweight.

How do I measure flyweight effectiveness?

Use dotMemory or PerfView to compare heap allocations before and after. Check Gen0/Gen1 collection counts and working set size. BenchmarkDotNet can measure allocation rates. If you're not seeing significant reduction in allocations or memory footprint, the pattern isn't helping.

Back to Articles