Why EXCEL.EXE Processes Remain After C# Excel COM Automation — Reference Release Patterns and the Replacement Decision

· · Excel, C#, COM, .NET, .NET Framework, Office, Legacy Maintenance, Technical Consulting

“We built a feature that outputs reports to Excel, and Task Manager ended up with a long row of EXCEL.EXE entries.” “I closed the app, but the next time I try to open the file it says ‘this file is in use by another process.’” “Hundreds of Excel processes piled up on the overnight batch server and ate all the memory until it ground to a halt.” Nearly every developer who has written C# code that drives Excel through Microsoft.Office.Interop.Excel runs into this at least once. We regularly get consulted with “I’m calling Quit(), but Excel won’t exit.”

What makes this troublesome is that it looks like it “only happens sometimes.” The process disappears on a dev machine but stays around in production; it stays around under the debugger but disappears in a release build — because the reproduction conditions keep shifting, a symptomatic Process.Kill tends to sneak into production code as a workaround. But the cause isn’t luck at all — it can be explained completely by COM’s reference counting and the mechanics of .NET’s RCW (Runtime Callable Wrapper). This article works through, from a practical standpoint: the mechanism behind the leftover process, the classic “two-dot rule” trap, a comparison of the two release-pattern schools and our recommendation, the correct way to do a last-resort process kill, and when to replace COM interop with an Open XML-based library.

1. The Short Version

  • EXCEL.EXE staying alive isn’t a bug — it’s because the .NET side is still holding a COM reference. Quit() is nothing more than a request that says “exit once every reference has been released”; as long as a reference remains, Excel dutifully keeps waiting.
  • .NET handles COM objects through a wrapper called the RCW (Runtime Callable Wrapper), and it keeps holding a reference to the COM object until the RCW is collected by the GC (or explicitly released).1
  • Chaining two or more dots, as in book.Worksheets[1].Range["A1"], creates RCWs for the intermediate objects that never get assigned to any variable, and they end up never being released. This is commonly known as the “two-dot rule,” and it’s the prime suspect behind this problem.
  • There are two schools of thought on releasing references: (a) disciplined application of Marshal.ReleaseComObject to every COM object, and (b) confining references to local variables and letting the GC (GC.Collect + WaitForPendingFinalizers) collect them. The official documentation positions ReleaseComObject as something to “use only if it is absolutely required.”2
  • Our recommendation is to default to the GC pattern with the work isolated in a single method, and only introduce a small using-based wrapper that disciplines the release when you genuinely need to control release order (Section 4).
  • As insurance for cases where the process still won’t go away, capture the window handle from Application.Hwnd, resolve its PID with GetWindowThreadProcessId, and kill that process. Don’t use the “diff the process list before and after launch” method to identify the target — it risks killing an Excel instance the user has open.34
  • And more fundamentally, Microsoft does not support Office automation in server-side, unattended environments.5 If you’re generating reports unattended, consider replacing COM interop with Open XML SDK / ClosedXML, which never launches Excel at all, before anything else (Sections 6–7).67

2. Why EXCEL.EXE Stays Running — Reference Counting and the RCW

Start with the principle on the COM side. A COM object’s lifetime is managed by reference count, and Excel’s automation server (EXCEL.EXE) doesn’t exit until every reference handed out to external clients has been returned. Back in the VB6 era, forgetting to call Release left the process behind in exactly the same way (for a refresher on COM, see “What Are COM / ActiveX / OCX?”).

Now the .NET side. C# code never touches a COM object directly — it operates through a proxy the CLR generates, called the RCW. Exactly one RCW is created per COM object within a process; it caches the COM interface pointer, and releases its reference to the COM object when it is itself collected by the GC.1 In other words, lifetime management has shifted from “counting the reference count yourself” to “leaving it to the GC.”

Combine these two facts and the reason EXCEL.EXE stays running falls right out.

Stage What’s happening
new Excel.Application() EXCEL.EXE starts up, and the RCW for the Application object is created
Cell operations, saving, etc. An RCW accumulates for every object touched — Workbook, Worksheet, Range, and so on
excel.Quit() Just tells Excel “you may exit now.” Not a single reference held by an RCW is released
Method returns The .NET reference to the RCW disappears, but the RCW itself is still alive on the heap
GC (whenever it runs) The RCW is collected, and only then is the COM reference returned — this is what finally lets EXCEL.EXE exit

Two things to take away. First, Quit() is not a release. As long as a reference remains, Excel waits. Under normal circumstances, once the host application process fully exits, the reference goes away and Excel exits too — but in forms where the parent process keeps living, such as a resident app or a web application, that “eventually” never arrives. Second, the timing of the release is indeterminate, because it depends on the GC. When memory is plentiful, the GC may not run for tens of minutes, and during that whole stretch EXCEL.EXE sits there as a zombie. The lack of reproducibility — “it stays around sometimes,” “it only stays around in production” — is nothing more than GC timing jitter showing through.

Note that Excel started with Visible = false has no window, so a leftover instance is invisible to the user. The symptom typically shows up as “a ‘file in use’ error on the second save” or “the PC feels slow,” and it’s only when someone opens Task Manager that they notice the row of EXCEL.EXE entries. The first move in any investigation is counting them with tasklist | findstr EXCEL.

3. The Classic Trap: the “Two-Dot Rule” — Invisible Intermediate Objects

Code behind the complaint “I’m releasing every variable properly, and it still stays running” almost always contains a line like this.

// Looks clean at a glance, but it's creating RCWs that can never be released
excel.Workbooks.Open(path);
book.Worksheets[1].Range["A1"].Value2 = "hello";

excel.Workbooks creates and returns an RCW for the Workbooks collection. If you call .Open(...) on it without capturing the return value in a variable, the Workbooks RCW is left on the heap as an anonymous object that nothing references, yet is still alive. Since there’s no variable, there’s no way to call Marshal.ReleaseComObject on it either. The second line is even worse — it creates three anonymous RCWs in a single line: Worksheets (the collection), Worksheets[1] (the sheet), and Range["A1"] (the range).

The rule of thumb for avoiding this, long known in the Office automation community, is the “two-dot rule.” Put another way: never chain two or more dots on a COM object — capture every intermediate object in a variable first.

// Give every intermediate object a name
Excel.Workbooks books = excel.Workbooks;
Excel.Workbook book = books.Open(path);
Excel.Sheets sheets = book.Worksheets;
Excel.Worksheet sheet = (Excel.Worksheet)sheets[1];
Excel.Range cell = sheet.Range["A1"];
cell.Value2 = "hello";

This looks verbose, but the point is to keep every object that needs releasing enumerable. Here are some easily-missed variants of the same pattern.

  • foreach: foreach (Excel.Worksheet s in book.Worksheets) creates RCWs for the collection, the enumerator, and each element. In the ReleaseComObject school, the standard practice is to use an indexed for loop that captures each item into a variable one at a time.
  • Throwaway use inside a conditional expression: an RCW is created even inside an expression like if (excel.Workbooks.Count > 0).
  • A compound expression as an argument: an expression like sheets.Add(After: sheets[sheets.Count]) creates several anonymous RCWs in a single line.
  • Event subscriptions: attaching a handler to an Application or Workbook event keeps a reference alive through that connection. Always unsubscribe before shutting down.

4. Comparing Release Patterns — the ReleaseComObject School and the GC School

There are two schools of writing code that reliably terminates EXCEL.EXE. Both work if written correctly. The real question is whether you can keep writing them correctly, and that’s where the practical difference shows up.

4.1 (a) Disciplined Application of Marshal.ReleaseComObject

Marshal.ReleaseComObject decrements the RCW’s internal reference count, and the instant it hits zero, the COM reference the RCW holds is released immediately.2 The advantage is that release happens at a deterministic moment, without waiting on the GC. Here’s the typical shape when it’s applied to every object.

using Excel = Microsoft.Office.Interop.Excel;
using System.Runtime.InteropServices;

Excel.Application excel = null;
Excel.Workbooks books = null;
Excel.Workbook book = null;
Excel.Sheets sheets = null;
Excel.Worksheet sheet = null;
Excel.Range cell = null;
try
{
    // Don't use an object initializer (new ... { DisplayAlerts = false }).
    // If the setter's COM call fails, excel would still be unassigned when
    // we hit finally, leaving no way to Quit or release the EXCEL.EXE that
    // has already launched
    excel = new Excel.Application();
    excel.DisplayAlerts = false;
    books = excel.Workbooks;
    book = books.Open(templatePath);
    sheets = book.Worksheets;
    sheet = (Excel.Worksheet)sheets[1];
    cell = sheet.Range["A1"];
    cell.Value2 = "hello";
    book.SaveAs(outputPath);
}
finally
{
    // Release in reverse order of creation. Close and Quit themselves are COM
    // calls that can also fail, so nest try/finally blocks to guarantee we
    // still reach Quit and release even if something throws partway through
    if (cell   != null) Marshal.ReleaseComObject(cell);
    if (sheet  != null) Marshal.ReleaseComObject(sheet);
    if (sheets != null) Marshal.ReleaseComObject(sheets);
    try
    {
        if (book != null) book.Close(SaveChanges: false);
    }
    finally
    {
        if (book  != null) Marshal.ReleaseComObject(book);
        if (books != null) Marshal.ReleaseComObject(books);
        try
        {
            if (excel != null) excel.Quit();
        }
        finally
        {
            if (excel != null) Marshal.ReleaseComObject(excel);
        }
    }
}

The weakness of this approach, as you can see, is that the discipline required is expensive. Every single COM object you touch must be captured in a variable and released in reverse order, including along exception paths. And once you factor in that Close or Quit itself can fail (a COM error, a disconnected workbook, an unresponsive Excel instance), you have to guarantee — with nested try/finally as shown above — that a failure partway through still reaches the subsequent releases. In our experience, expecting every team member to uphold this on every single change is a genuinely hard ask. A single stray two-dot compound expression anywhere brings the leak right back.

What’s more important still is that the official documentation itself warns against misuse. The reference for Marshal.ReleaseComObject states plainly that it’s a tool for when resources need to be released promptly or when release order matters, and that you should “use the ReleaseComObject only if it is absolutely required.”2 Because an RCW is shared — exactly one per COM object per process — if code in one place releases an RCW that another part of the codebase is still using, you get an InvalidComObjectException or, in the worst case, an access violation or memory corruption of the process.2 In an architecture where multiple modules within an app share Excel operations, this kind of accident is a real risk.

One more detail: when the same interface pointer is passed to the CLR repeatedly, the RCW’s internal reference count can exceed 1, in which case a single call won’t release it. There’s also Marshal.FinalReleaseComObject, which forces the count to 02, but the moment you need that API is itself a sign that you’ve lost track of the object’s lifetime, and we recommend revisiting the design instead.

One security note that’s a separate axis from process leakage. A workbook opened via Workbooks.Open through COM automation can run VBA without any macro warning. The sample in this article assumes it’s opening a trusted template your own app manages, but if there’s any chance you’ll open a workbook from an external source — a file in a shared folder, something a user uploaded — set excel.AutomationSecurity = MsoAutomationSecurity.msoAutomationSecurityForceDisable (in the Microsoft.Office.Core namespace) before calling Open, to forcibly disable macros.8 That closes off the attack path where swapping out a template turns directly into arbitrary code execution. This caveat applies just as much to the GC-pattern sample below.

4.2 (b) Confining References and Letting the GC Collect Them

The other approach is to leave RCW release to the GC, exactly as the mechanism was designed, and run that GC at a deterministic moment. Since an RCW releases its COM reference when the GC collects it1, you can terminate EXCEL.EXE without writing a single call to ReleaseComObject by “running a full GC plus waiting for finalizers to complete, after every reference touching Excel has gone out of scope.”

using System.Runtime.CompilerServices;
using Excel = Microsoft.Office.Interop.Excel;

public void ExportReport(string templatePath, string outputPath)
{
    try
    {
        // Fully isolate the Excel-touching work into a separate method
        ExportReportCore(templatePath, outputPath);
    }
    finally
    {
        // It's precisely the exception path where EXCEL.EXE tends to linger,
        // so always run this in finally. Once the method has returned, no
        // reference to any RCW remains anywhere
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();   // Second pass to collect the RCWs the finalizer detached
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ExportReportCore(string templatePath, string outputPath)
{
    var excel = new Excel.Application();
    try
    {
        excel.DisplayAlerts = false;
        Excel.Workbooks books = excel.Workbooks;
        Excel.Workbook book = books.Open(templatePath);
        Excel.Sheets sheets = book.Worksheets;
        Excel.Worksheet sheet = (Excel.Worksheet)sheets[1];
        Excel.Range cell = sheet.Range["A1"];
        cell.Value2 = "hello";
        book.SaveAs(outputPath);
        book.Close(SaveChanges: false);
    }
    finally
    {
        excel.Quit();
    }
}

There are three conditions for this to hold.

  1. Isolate the code that touches Excel into a single method. As long as the JIT is keeping a reference alive on the stack, the GC cannot collect it, so the GC call must always happen outside the method that touched Excel. NoInlining exists to keep inlining from defeating that isolation.
  2. Never let an RCW escape into a field or a return value. If even one leaks outside the method, Excel stays alive as long as that reference does.
  3. Use the three-step combo GC.CollectGC.WaitForPendingFinalizersGC.Collect. Since RCW cleanup happens via a finalizer, the standard pattern is two passes: the first Collect detects it, you wait for the finalizer to finish, and the second Collect sweeps up what’s left.

One caveat: when a debugger is attached, a variable’s lifetime is extended to the end of the method, so even this approach can fail to collect the RCW. This is the real identity behind the “it stays around under the debugger but disappears in release” phenomenon — always verify behavior with a release build and no debugger attached.

The advantage of this approach is that breaking the two-dot rule doesn’t cause a leak. Everything whose reference has gone out of scope — including anonymous intermediate RCWs — gets swept up together by the GC. Since what a reviewer needs to check collapses down to a single question — “is the Excel-touching work confined to this method?” — the cost of discipline drops dramatically. The downsides are the code smell of an explicit GC.Collect call (a full, app-wide GC pause) and the risk that a well-meaning refactor by someone who doesn’t know why it’s there breaks it. Always leave a comment explaining the reason behind the three-step combo.

4.3 Our Recommendation — Isolation + GC by Default, a Wrapper for Discipline When Needed

Comparing the two schools from a practical standpoint:

  (a) ReleaseComObject school (b) GC school
Release timing Deterministic (the instant it’s called) Semi-deterministic (the moment of the three-step GC combo)
Discipline cost High. Everyone must consistently turn every object into a variable and release in reverse order Low. Only method isolation needs to be maintained
Symptoms of overuse InvalidComObjectException, access violations2 Pauses from forced GC
Alignment with official guidance “Only when absolutely required”2 Follows the RCW’s intended lifetime management (leave it to the GC)1
Good fit When release order matters. Long-lived processes that touch Excel repeatedly in small bursts Most report-generation work, where “open, write, close” can be completed in one place

Our recommendation is to default to the isolation-plus-GC pattern (b). Work like report generation naturally fits inside a single method, and once it’s shaped that way, leaks simply can’t occur structurally. It also avoids the risk of ReleaseComObject misuse and lines up with how the official documentation positions it.

Reserve (a) for cases where release order and immediacy genuinely matter — and in those cases, don’t let raw ReleaseComObject calls get written at all; introduce a small using-based wrapper that disciplines the release instead.

using System.Runtime.InteropServices;

/// <summary>Wrapper that releases a COM object at the end of a using scope</summary>
public readonly struct ComScope<T> : IDisposable where T : class
{
    public T Value { get; }
    public ComScope(T value) => Value = value;

    public void Dispose()
    {
        if (Value is not null && Marshal.IsComObject(Value))
            Marshal.ReleaseComObject(Value);
    }
}
using var books = new ComScope<Excel.Workbooks>(excel.Workbooks);
using var book  = new ComScope<Excel.Workbook>(books.Value.Open(templatePath));
using var sheets = new ComScope<Excel.Sheets>(book.Value.Worksheets);
using var sheet = new ComScope<Excel.Worksheet>((Excel.Worksheet)sheets.Value[1]);
using var cell  = new ComScope<Excel.Range>(sheet.Value.Range["A1"]);
cell.Value.Value2 = "hello";
book.Value.SaveAs(outputPath);
book.Value.Close(SaveChanges: false);

Dispose runs in the reverse order of the using declarations, so “release in reverse order of creation” is guaranteed by the language mechanism itself. Writing costs a bit more due to the .Value accessor, but the discipline compresses down to a single rule: “every COM object must be captured through a ComScope.” Conversely, writing a compound expression like books.Value.Open(...).Worksheets brings the leak right back, so teaching the two-dot rule is still necessary either way.

Two cautions apply to either pattern. First, don’t let a save-confirmation dialog pop up before Quit() — set DisplayAlerts = false and call Close(SaveChanges: false) explicitly. If a hidden Excel instance hangs waiting for a dialog, Quit() itself never completes. Second, since Excel’s COM is built around STA, never pass the same Application instance around across multiple threads. The relationship between threads and COM apartments is covered in “STA/MTA Basics for COM.”

5. The Last Resort — Reliably Cleaning Up by Identifying the PID from the Hwnd

Even with a correctly implemented release pattern, cases remain where “Excel won’t exit because of an add-in” or “an abnormal exception path occasionally leaves one process behind.” In an unattended batch job, that one leftover process can cause a file lock for the next day’s job, so it’s worth building in a process kill as a last-resort safety net. The remaining question is how to identify “which EXCEL.EXE to kill.”

A common mistake is identifying it by diffing the process list before and after launch — comparing Process.GetProcessesByName("EXCEL") before and after and treating the increase as your own instance. This is fragile against concurrency: if a user opens Excel by hand while you’re taking the diff, you get a false positive, and if the same kind of job runs in parallel, jobs mistake each other’s processes for their own. The worst-case accident with this method is killing an unsaved Excel instance the user is actively editing and losing their data — something that has in fact been brought to us as a real consulting case.

The correct method is to get the top-level window handle from the Hwnd property of the Application object you yourself launched, and use the Win32 API GetWindowThreadProcessId to get the ID of the process that created that window.34 The handle is unique to your own instance, so there’s no room to confuse it with another Excel process.

using System.Diagnostics;
using System.Runtime.InteropServices;
using Excel = Microsoft.Office.Interop.Excel;

internal static class NativeMethods
{
    [DllImport("user32.dll")]
    internal static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}

public void ExportReport(string templatePath, string outputPath)
{
    // Using an out parameter means the handle survives back to the caller even
    // if an exception is thrown mid-way through the Excel operations, so GC and
    // Kill are still reached on the exception path too (exactly where this
    // safety net is actually needed)
    Process excelProcess = null;
    try
    {
        ExportReportCore(templatePath, outputPath, out excelProcess);
    }
    finally
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        if (excelProcess != null)
        {
            KillIfStillAlive(excelProcess);   // A no-op safety net if it already exited normally
            excelProcess.Dispose();
        }
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
private void ExportReportCore(string templatePath, string outputPath, out Process excelProcess)
{
    excelProcess = null;
    var excel = new Excel.Application();
    // Wrap this in try/finally immediately after a successful launch, so that
    // even if getting the Hwnd or opening the handle fails, we still always
    // reach Quit()
    try
    {
        // Capture this right after launch, since the window disappears and
        // becomes unobtainable after Quit(). GetProcessById only maps the PID;
        // the OS process handle is opened lazily on first access — e.g. by
        // WaitForExit / Kill. So we touch SafeHandle here, while Excel is still
        // alive, to force the handle open right away (as long as the handle
        // stays open, this PID won't be reused by another process)
        NativeMethods.GetWindowThreadProcessId((IntPtr)excel.Hwnd, out uint pid);
        excelProcess = Process.GetProcessById((int)pid);
        _ = excelProcess.SafeHandle;

        excel.DisplayAlerts = false;
        // …… Excel operations ……
    }
    finally
    {
        excel.Quit();
    }
}

private void KillIfStillAlive(Process excelProcess)
{
    if (!excelProcess.WaitForExit(5000))   // Normally it disappears within a few seconds
    {
        logger.LogWarning("EXCEL.EXE (PID {Pid}) did not exit, forcibly terminating it", excelProcess.Id);
        excelProcess.Kill();
    }
}

Implementation notes:

  • Capture Hwnd right after launch. After Quit(), the window is destroyed and can no longer be retrieved. Application.Hwnd can be obtained even with Visible = false.3
  • Kill is a safety net on top of doing the release correctly, not a substitute for it. Relying on it from the start means Excel’s temp-file cleanup never runs, which can lead to a buildup of auto-recovery files the next time it launches. The order must always be “release → Quit → wait → Kill only if it’s still alive.”
  • Watch out for PID reuse. Because Windows PIDs get reused, a design that only remembers the PID number and resolves it later risks killing an unrelated process if Excel exits in the meantime and the same PID gets assigned to something else. Note that merely calling Process.GetProcessById here isn’t enough by itself — the OS process handle is opened lazily on first access, via calls like WaitForExit / Kill. Only by touching SafeHandle while Excel is still alive to explicitly force the handle open, as in the code above, do you actually prevent this mix-up (as long as the handle stays open, that PID won’t be reused).
  • What this safety net covers is the case where the method has already returned yet EXCEL.EXE is still there. If the COM call itself hangsWorkbooks.Open, SaveAs, Quit, waiting on a hidden modal dialog or an unresponsive add-in — finally is never reached, and this Kill never fires either. Think of the countermeasure in two layers. First, close off the dialog sources with DisplayAlerts = false and the AutomationSecurity setting described earlier. On top of that, for unattended batch jobs, structure the Excel-touching job as its own separate process so it can be killed wholesale from the outside under a time limit — Task Scheduler’s “stop task if it runs longer than” setting, or a parent process killing its child. An in-process timeout cannot interrupt a hung COM call, so it’s more reliable to place the boundary at the process level.
  • Whenever Kill fires, always log it and monitor how often it happens. A rising frequency is a signal either of regression in the release code, or of a decision point covered in Section 7 for replacing COM interop altogether.

6. Stepping Back — Server-Side Office Automation Isn’t Supported

The techniques covered so far can suppress EXCEL.EXE leakage in a client application almost entirely. But for the scenario where this problem gets most severe — Excel automation on a server or in an unattended environment — there’s an official position worth checking before reaching for any technique at all.

In a document titled “Considerations for server-side Automation of Office,” Microsoft states plainly that it does not recommend, and does not support, automating Office from unattended, non-interactive client applications or components — including ASP, ASP.NET, DCOM, and NT services.5 Office is designed on the assumption that an interactive user is present, and premises that cause no trouble on a desktop — a design that pops up a confirmation dialog on error and waits for a response, components that assume the executing user’s profile, an STA-based, non-reentrant architecture — all turn hostile on a service or an IIS worker process.9 Cases we’ve been consulted on — “Open never returns in production when run from a Windows service,” “it hangs waiting on a dialog once a month on IIS” — have every one traced back to this unsupported configuration. EXCEL.EXE leakage in this kind of setup isn’t just a cleanup problem — it becomes an operational problem where hung processes accumulate without limit.

What Microsoft names as alternatives are editing the Open XML file format directly, without installing or launching Office, and the Microsoft Graph API, which processes on the cloud side.9 The Open XML SDK is a Microsoft library that reads and writes Office’s file formats (such as .xlsx), standardized as ECMA-376 / ISO/IEC 29500, through strongly typed classes; because it’s built on top of ZIP and XML, Excel itself isn’t required.6 The problems of process leakage, licensing, and unsupported configurations all disappear at once.

That said, the Open XML SDK is a library that “edits the file format directly,” and it does not provide the behavior of the Excel application itself. Its official design considerations state explicitly that it does not provide application behaviors such as recalculating formulas or refreshing data, nor conversion to other formats like PDF.10 The API also stays faithful to the file format’s structure, so even writing a single cell with the bare SDK requires understanding the structure of SpreadsheetML. ClosedXML fills that gap — an MIT-licensed OSS library that layers an intuitive “workbook, sheet, cell” API on top of the Open XML API. It can handle .xlsx / .xlsm without Excel installed (the legacy .xls format is out of scope).7 How to choose between these approaches in the context of report generation, and how to design a template-based approach, are covered in detail in “How to Build Excel Report Output.”

Note that Microsoft 365 does have an unattended license for RPA, but this only makes unattended execution possible from a licensing standpoint; behavior is still positioned as “AS IS” — any unexpected behavior arising from a use case outside the design is something the application itself has to absorb.9 This is a frequent point of confusion: buying the license does not turn it into a supported configuration.

7. Decision Table — Keep Using COM, or Replace It with Open XML

Given all this, the question of “keep operating Excel via COM, or move away from it” boils down to the following three options.

  (1) Keep using COM Interop (wrapped and disciplined) (2) Replace with Open XML SDK / ClosedXML (3) Rethink the design entirely (Graph, etc.)
Excel itself Required (and a license per execution environment) Not required Not required
Unattended / server execution Unsupported configuration5 No problem (the recommended alternative)9 No problem
Process-leak risk Present (managed via the techniques in this article) None (never launches a process) None
Running macros (VBA) Yes No (and even preservation depends on the library — see item 2 below) No
Formula recalculation, printing, PDF conversion Yes No10 Partly available via Graph
Interacting with an Excel instance the user has open Yes No No
Legacy .xls format (BIFF) Read/write No (.xlsx / .xlsm only)7
Execution speed / parallelism Slow. Instance isolation needed for parallelism9 Fast. Can run in parallel like a normal library Depends on the network

The decision comes down to four questions.

  1. Does it need to interact with the Excel instance in front of the user? A feature that “writes into a workbook the user has open and hands control back for them to continue” can only be built with COM Interop. In that case, (1) is the only option — invest in the discipline described in Section 4. Interactive desktop apps don’t fall under the unsupported configuration either.
  2. Does it need capabilities of the Excel application itself — running macros, recalculation, printing, PDF output? These cannot be substituted by the Open XML family.10 Combining these with unattended execution is the hardest pattern of all, so first look at whether the requirement itself can be reshaped — porting the macro’s logic to C#, writing already-computed values, and so on. One thing worth watching carefully is whether a macro-enabled template (.xlsm) actually “survives”. Low-level operations with the Open XML SDK preserve it as long as you don’t touch the VBA project part, but a high-level library like ClosedXML loads the workbook into an object model and rebuilds and saves the package, which can lose the VBA project. If you’re migrating a macro-enabled template to the Open XML family, make it mandatory to verify against the real template that “the macro still remains and works after opening, writing, and saving” — and if you can’t guarantee that, keep that specific report on COM. For handling VBA assets, the judgment framework in “What Are VBA’s Limits?” applies directly here too.
  3. Is it unattended? If it runs from a service, Task Scheduler, or a web app, the default answer is (2). The bulk of report-generation requirements amount to nothing more than “produce an .xlsx populated with values and styles,” and that’s fully achievable with ClosedXML.
  4. What format is the input/output? If you need to process incoming .xls files from a business partner as-is, the Open XML family is off the table. Consider whether you can insert a conversion to .xlsx at the intake point instead.

What we often propose in actual engagements is a split: generate with ClosedXML, and isolate only the work that genuinely needs Excel itself into COM. Run the daily generation of hundreds of reports server-side with ClosedXML, and reserve COM processing — a button click on the responsible person’s desktop — for just the monthly “update the macro-enabled workbook” task. That removes COM from the unattended environment entirely, and the COM part that remains is an interactive app, so it falls within a supported configuration. It’s more realistic than a full rewrite, and it lets you eliminate risk starting from the riskiest part first.

8. Notes for the .NET (Core) Era

Here’s a rundown, to the extent we’ve been able to verify, of things to watch for when continuing Excel COM operations in an app that has migrated from .NET Framework to .NET (.NET 6/8, etc.).

  • COM interop remains Windows-only. .NET runs on Linux too, but its built-in COM interop support is limited to Windows.11 For a project that includes Excel operations, explicitly declare a target like net8.0-windows, and don’t expect cross-platform support. The fact that it can’t be hosted on a Linux container ties directly into the replacement decision in Section 7 (ClosedXML, by contrast, does run in a Linux container).
  • The standard reference approach is “COM reference plus embedded interop types.” When you add the Microsoft Excel Object Library as a COM reference in Visual Studio, Embed Interop Types is used by default. Only the types you actually use get embedded into your own assembly, so there’s no need to deploy a PIA (Primary Interop Assembly) to the runtime environment, and it’s more resilient to Office version differences.12
  • dynamic and optional arguments still work. The C# features built for Office interop — named and optional arguments, and simplifying COM calls with dynamic — remain supported in current .NET.12 That said, writing with dynamic makes anonymous RCWs even harder to spot, so in code where you want disciplined release, we recommend writing with explicit types instead.
  • Watch for platform-specific APIs. COM-related APIs such as Marshal.ReleaseComObject carry a Windows-only attribute, and calling them from a cross-platform project triggers an analyzer warning (CA1416). Pulling Excel operations out into their own separate project makes this easier to manage.
  • The reverse direction — calling .NET from VBA — is a different story. A setup where a .NET 8 DLL is exposed as COM and consumed from VBA remains possible; the procedure is covered in “How to Consume a .NET 8 DLL from VBA with Type Information.” Flipping the architecture around — instead of “operating Excel from C#,” having “Excel’s macro call into C# logic” — lets Excel itself manage the process lifetime, and in some cases the leakage problem disappears structurally as a result.

In short, even in the .NET (Core) era, the way you write Excel COM operations and the traps involved are nearly identical to the .NET Framework era. What changed is that you now declare Windows-only status explicitly, and references have shifted toward embedded interop types; the discussion of RCWs and release patterns (Sections 2–5) applies just as it did before.

9. Summary

The problem of EXCEL.EXE staying alive stops being a matter of luck once you understand the bridge between two different lifetime-management systems: “COM lives by reference count, and .NET’s RCW dies via the GC.” Compressed into a checklist:

  • Quit() is not a release. Excel doesn’t exit until every COM reference held by an RCW has been returned.
  • A compound expression with two or more dots creates an anonymous RCW. Capture intermediate objects in variables.
  • Default to “method isolation plus the three-step GC combo” for release; if order control is genuinely needed, discipline ReleaseComObject with a using wrapper. Don’t scatter raw ReleaseComObject calls around.
  • For the safety-net kill, identify only your own instance via Application.HwndGetWindowThreadProcessId, and trigger it only under “Quit → wait → timeout.”
  • Server-side, unattended Excel automation is an unsupported configuration. Move unattended report generation to ClosedXML / Open XML SDK, and confine work that needs macros or recalculation to COM in an interactive environment.

If you find a row of EXCEL.EXE entries in Task Manager, it’s a sign of either a technique problem (Sections 4–5) or a configuration problem (Sections 6–7). If you’re unsure which one your code falls into, or where to start if you do decide to replace it, we can help starting with an inventory of the processing involved.

KomuraSoft LLC handles trouble investigation for Windows apps involving Excel/Office automation (process leakage, hangs, file locks), maintaining and disciplining COM assets, and designing migrations of report processing to Open XML-family libraries.

References

  1. Microsoft Learn, Runtime Callable Wrapper. On how exactly one RCW is created per COM object within a process, how it caches the interface pointer, and how it releases its reference to the COM object when collected by the GC.  2 3 4

  2. Microsoft Learn, Marshal.ReleaseComObject(Object) Method. On how it decrements the RCW’s reference count, the risk of InvalidComObjectException, access violations, and memory corruption from using an already-released RCW, its positioning as something to “use only if absolutely required,” and its relationship to FinalReleaseComObject.  2 3 4 5 6 7

  3. Microsoft Learn, Application.hWnd property (Excel). On how the Hwnd property of Excel’s Application object returns the handle of its top-level window.  2 3

  4. Microsoft Learn, GetWindowThreadProcessId function (winuser.h). On how it retrieves the ID of the thread that created a given window, and the ID of the process that created it.  2

  5. Microsoft Support, Considerations for server-side Automation of Office. On Microsoft not recommending or supporting Office automation from unattended, non-interactive client applications or components, including ASP, ASP.NET, DCOM, and NT services.  2 3

  6. Microsoft Learn, Welcome to the Open XML SDK for Office. On the Open XML SDK being a System.IO.Packaging-based library that manipulates Office file formats standardized as ECMA-376 / ISO/IEC 29500 through strongly typed classes.  2

  7. GitHub, ClosedXML/ClosedXML. On it being an MIT-licensed library that provides an intuitive interface on top of the Open XML API, able to handle Excel 2007+ (.xlsx, .xlsm) files without Excel installed.  2 3

  8. Microsoft Learn, _Application.AutomationSecurity Property (Microsoft.Office.Interop.Excel). On the macro security mode used when a file is opened programmatically, how the default at application startup is msoAutomationSecurityLow (all macros enabled), and how msoAutomationSecurityForceDisable disables all macros without a warning. 

  9. Microsoft Learn, Considerations for unattended automation of Office in the Microsoft 365 for unattended RPA environment. On the problems with interactive UI, user identification, and the single-threaded STA design in unattended automation, on how behavior remains AS IS even under an unattended license, and on Microsoft Graph and direct editing of the Open XML file format being recommended as alternatives.  2 3 4 5

  10. Microsoft Learn, Open XML SDK for Office design considerations. On the Open XML SDK not being a substitute for the Office object model, and not providing application behaviors such as formula recalculation or data refresh, nor conversion to other formats.  2 3

  11. Microsoft Learn, Native interoperability ABI support. On how support for the built-in COM interop system is limited to Windows, and on COM support via ComWrappers in .NET 5+ and source generation in .NET 8+. 

  12. Microsoft Learn, How to access Office interop objects. On simplifying Office interop through named arguments, optional arguments, and dynamic, and on Embed Interop Types being the default behavior in place of a PIA.  2

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog