Single-Instance .NET Apps: Mutexes, Named Pipes, UX

Why Single-Instance Matters

If you've ever double-clicked an installer shortcut and watched two windows appear, you've seen what happens when applications ignore single-instance patterns. Users get confused, resources duplicate, and file locks conflict. Email clients, chat apps, and system tray tools need to prevent this chaos.

Restricting your application to one running instance prevents resource conflicts and improves user experience. When users launch an already-running app, you can bring the existing window to the front instead of showing an error. File-based applications can open the document in the existing instance rather than failing with "file in use" errors.

You'll build a Mutex-based detector that atomically checks for existing instances, then add named pipe communication to pass arguments between processes. By the end, you'll handle activation properly and know when single-instance enforcement helps versus when it gets in the way.

Using Mutex for Atomic Instance Detection

A Mutex (mutual exclusion) is a system-wide synchronization primitive. Only one process can own a named mutex at a time. When your app starts, it tries to create or open a mutex with a unique name. If creation succeeds, this is the first instance. If it fails, another instance is already running.

The key is choosing a globally unique mutex name. Use your application's GUID or combine company name with product name. Avoid common words that might collide with other apps. The mutex persists until the owning process exits, so cleanup happens automatically even if your app crashes.

SingleInstanceApp.cs
using System.Threading;

public class SingleInstanceChecker : IDisposable
{
    private Mutex? _mutex;
    private bool _isFirstInstance;

    public bool IsFirstInstance => _isFirstInstance;

    public SingleInstanceChecker(string appGuid)
    {
        var mutexName = $"Global\\{{{appGuid}}}";

        try
        {
            _mutex = new Mutex(true, mutexName, out _isFirstInstance);
        }
        catch (UnauthorizedAccessException)
        {
            // Mutex exists but we can't access it (different user session)
            _mutex = null;
            _isFirstInstance = false;
        }
    }

    public void Dispose()
    {
        if (_mutex != null && _isFirstInstance)
        {
            _mutex.ReleaseMutex();
            _mutex.Dispose();
        }
    }
}

// Usage
var appGuid = "E4F7A392-8B1D-4C2E-9F3A-7D6B5C8E1A9F";
using var checker = new SingleInstanceChecker(appGuid);

if (!checker.IsFirstInstance)
{
    Console.WriteLine("Application is already running.");
    return 1; // Exit
}

Console.WriteLine("First instance - running normally...");
Thread.Sleep(30000); // Simulate work

The Global prefix makes the mutex visible across all user sessions, which matters for services or elevated processes. If you only care about the current user, use Local instead. The createdNew parameter tells you if this process created the mutex, making you the first instance. Keep the mutex object alive for your app's lifetime.

Bringing the Existing Instance to Front

Detecting a second instance is only half the problem. Users expect the existing window to activate when they launch the app again. On Windows, you need to find the main window and restore it if minimized. Platform-specific APIs handle this differently on Windows versus Linux.

WindowActivator.cs
using System.Runtime.InteropServices;
using System.Diagnostics;

public static class WindowActivator
{
    [DllImport("user32.dll")]
    private static extern bool SetForegroundWindow(IntPtr hWnd);

    [DllImport("user32.dll")]
    private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);

    [DllImport("user32.dll")]
    private static extern bool IsIconic(IntPtr hWnd);

    private const int SW_RESTORE = 9;

    public static bool ActivateExistingInstance(string processName)
    {
        var currentProcess = Process.GetCurrentProcess();
        var processes = Process.GetProcessesByName(processName);

        foreach (var process in processes)
        {
            if (process.Id == currentProcess.Id)
                continue; // Skip self

            var handle = process.MainWindowHandle;
            if (handle == IntPtr.Zero)
                continue; // No window

            // Restore if minimized
            if (IsIconic(handle))
            {
                ShowWindow(handle, SW_RESTORE);
            }

            // Bring to foreground
            SetForegroundWindow(handle);
            return true;
        }

        return false;
    }
}

This Windows-specific code finds other processes with the same name and brings their window forward. SetForegroundWindow moves the window to the top of the Z-order. If minimized, ShowWindow restores it first. Handle IntPtr.Zero carefully since console apps or services have no main window. This approach works for WPF, WinForms, and Windows Forms apps.

Passing Arguments with Named Pipes

When users double-click a document file, they expect it to open in the existing app instance. The second process needs to pass its command-line arguments to the first before exiting. Named pipes provide reliable inter-process communication for this scenario.

The first instance creates a named pipe server and listens for connections. Second instances connect as clients, send their arguments, then exit. The server receives arguments and processes them as if they came from its own command line. This works even when instances run under different user accounts with proper permissions.

ArgumentPasser.cs
using System.IO.Pipes;
using System.Text;

public class ArgumentPasser
{
    private readonly string _pipeName;
    private NamedPipeServerStream? _pipeServer;
    private CancellationTokenSource? _cts;

    public event Action<string[]>? ArgumentsReceived;

    public ArgumentPasser(string appGuid)
    {
        _pipeName = $"ArgPipe_{appGuid}";
    }

    public void StartListening()
    {
        _cts = new CancellationTokenSource();
        Task.Run(() => ListenForArguments(_cts.Token));
    }

    private async Task ListenForArguments(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                using var pipe = new NamedPipeServerStream(
                    _pipeName,
                    PipeDirection.In,
                    1,
                    PipeTransmissionMode.Byte,
                    PipeOptions.Asynchronous);

                await pipe.WaitForConnectionAsync(ct);

                using var reader = new StreamReader(pipe, Encoding.UTF8);
                var argsJson = await reader.ReadToEndAsync();
                var args = argsJson.Split('\n',
                    StringSplitOptions.RemoveEmptyEntries);

                ArgumentsReceived?.Invoke(args);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Pipe error: {ex.Message}");
            }
        }
    }

    public static async Task SendArguments(string appGuid, string[] args)
    {
        var pipeName = $"ArgPipe_{appGuid}";

        try
        {
            using var pipe = new NamedPipeClientStream(
                ".",
                pipeName,
                PipeDirection.Out);

            await pipe.ConnectAsync(2000); // 2 second timeout

            using var writer = new StreamWriter(pipe, Encoding.UTF8);
            await writer.WriteLineAsync(string.Join("\n", args));
            await writer.FlushAsync();
        }
        catch (TimeoutException)
        {
            Console.WriteLine("Could not connect to existing instance");
        }
    }

    public void StopListening()
    {
        _cts?.Cancel();
        _pipeServer?.Dispose();
    }
}

The server loops continuously, creating a new pipe for each connection. After reading arguments, it fires an event that your UI can handle. The client connects with a timeout to avoid hanging if the server stopped unexpectedly. Serializing arguments as newline-separated strings keeps the protocol simple without requiring JSON libraries.

Putting It All Together

A production-ready solution combines mutex checking, argument passing, and window activation. The first instance sets up the pipe listener and runs normally. Second instances send arguments through the pipe and exit. This handles file associations, command-line tools, and shortcut double-clicks correctly.

Program.cs
const string appGuid = "E4F7A392-8B1D-4C2E-9F3A-7D6B5C8E1A9F";

using var instanceChecker = new SingleInstanceChecker(appGuid);

if (!instanceChecker.IsFirstInstance)
{
    Console.WriteLine("Another instance detected - sending arguments...");

    await ArgumentPasser.SendArguments(appGuid, args);

    // Try to activate existing window
    WindowActivator.ActivateExistingInstance("MyApp");

    return 0; // Exit gracefully
}

Console.WriteLine("First instance - starting up...");

var argumentPasser = new ArgumentPasser(appGuid);
argumentPasser.ArgumentsReceived += receivedArgs =>
{
    Console.WriteLine($"Received args from second instance: {
        string.Join(", ", receivedArgs)}");
    // Process arguments (open file, navigate to URL, etc.)
};

argumentPasser.StartListening();

// Your main application logic here
Console.WriteLine("Running main application...");
Console.WriteLine("Press Ctrl+C to exit");

var exitEvent = new ManualResetEvent(false);
Console.CancelKeyPress += (s, e) =>
{
    e.Cancel = true;
    exitEvent.Set();
};

exitEvent.WaitOne();

argumentPasser.StopListening();

This pattern works for console apps, WPF, and WinForms. For WPF, hook ArgumentsReceived to invoke window activation on the dispatcher thread. The mutex lifetime matches your app's lifetime, and the pipe listener runs on a background thread. Clean shutdown stops the listener and releases the mutex.

Try It Yourself

This demo shows single-instance behavior with argument passing. Open multiple console windows and run simultaneously to see it work.

Steps

  1. Create with dotnet new console -n SingleInstanceDemo
  2. Change to cd SingleInstanceDemo
  3. Update Program.cs with the combined code below
  4. Modify SingleInstanceDemo.csproj as shown
  5. Run dotnet run arg1 arg2 in multiple terminals
Main.cs
// Combine all class definitions from above sections

Console.WriteLine($"=== Single Instance Demo ===");
Console.WriteLine($"Process ID: {Environment.ProcessId}");
Console.WriteLine($"Arguments: {string.Join(", ", args)}\n");

const string appGuid = "F8E3B1D9-7C4A-2E6B-9F1D-3A5C8E7B2D4F";

using var checker = new SingleInstanceChecker(appGuid);

if (!checker.IsFirstInstance)
{
    Console.WriteLine("Detected another instance running");
    Console.WriteLine("Sending arguments to first instance...\n");

    await ArgumentPasser.SendArguments(appGuid, args);
    Console.WriteLine("Arguments sent. Exiting.");
    return;
}

Console.WriteLine("This is the FIRST instance");
Console.WriteLine("Listening for arguments from other instances...\n");

var passer = new ArgumentPasser(appGuid);
passer.ArgumentsReceived += receivedArgs =>
{
    Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Received from another instance:");
    foreach (var arg in receivedArgs)
        Console.WriteLine($"  - {arg}");
    Console.WriteLine();
};

passer.StartListening();

Console.WriteLine("Try running this app again in another terminal");
Console.WriteLine("Press any key to exit...\n");
Console.ReadKey();

passer.StopListening();
SingleInstanceDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

What you'll see

Terminal 1:
=== Single Instance Demo ===
Process ID: 12345
Arguments: file1.txt, file2.txt

This is the FIRST instance
Listening for arguments from other instances...

Try running this app again in another terminal
Press any key to exit...

[14:32:15] Received from another instance:
  - data.csv
  - report.pdf

Terminal 2:
=== Single Instance Demo ===
Process ID: 12789
Arguments: data.csv, report.pdf

Detected another instance running
Sending arguments to first instance...

Arguments sent. Exiting.

Common Pitfalls

Forgetting to dispose the mutex properly causes the lock to persist even after your app exits abnormally. Always use a using statement or dispose in a finally block. Without cleanup, Windows holds the mutex until the process terminates, preventing new instances from starting if your app crashes.

Hardcoding mutex names without uniqueness causes collisions with other applications. Two apps using "MyAppMutex" conflict even if they're unrelated. Generate a GUID for your app and use it consistently. Document this GUID in your project since changing it after release breaks single-instance behavior.

Blocking the UI thread while waiting for pipe connections freezes your application. Always listen for pipe connections on a background thread or with async methods. Fire events to the main thread using dispatchers or synchronization contexts when arguments arrive. This keeps your UI responsive while handling inter-process messages.

FAQ

Should I use Mutex or check Process.GetProcessesByName?

Use Mutex. Process name checking has race conditions where two instances can start simultaneously. Mutex provides atomic checks that guarantee only one instance holds it. Process checking also fails if your executable name changes or runs from different paths.

How do I pass command-line args to the running instance?

Use named pipes or memory-mapped files for inter-process communication. The second instance sends args through the pipe, then exits. The first instance listens on the pipe and processes received arguments. This works across user sessions and handles Unicode properly.

What if users need multiple instances for testing?

Add a command-line flag like --allow-multiple-instances to bypass the check. Developers and QA can use this for testing while production users get single-instance behavior. Document this clearly in your help text and consider environment variable toggles.

Back to Articles