When Things Break Because You Didn't Close Them Properly

I spent three days debugging a production incident where file handles were leaking because someone used a library that relied on finalizers instead of explicit Dispose calls. The server had been running fine for two years, then started throwing IO exceptions at 2 AM on a Tuesday. Not dramatic, just the consequence of a pattern that works until it doesn't. This is what The Art Of Clean Up actually means in practice. It's not philosophy. It's knowing which cleanup path your code takes and whether that path actually runs when you need it to.

The Art Of Clean Up in resource management

There are fundamentally two approaches to cleaning up resources in managed environments. The first is deterministic: you explicitly release the resource when you're done with it. IDisposable in C#, try-with-resources in Java, context managers in Python. The second is non-deterministic: the runtime decides when to reclaim the memory or release the handle, usually through garbage collection or finalization. The deterministic approach is almost always better for external resources like file handles, network connections, database connections, and native memory. The GC approach has a place, but it introduces unpredictable timing. An object might sit around for minutes or hours before its finalizer runs, holding onto whatever resource it wraps during that entire time. I learned this the hard way with a background processing service that queued tens of thousands of PDF conversions per day. Each conversion opened a temporary file, wrote to it, and closed it. The library we used had a wrapper class that implemented IDisposable correctly, but the calling code only used it in a using statement when the caller remembered. Most of the time it was called without one. The finalizer would eventually close the file, but the GC didn't run often enough under that workload. After about six hours the process hit the OS file handle limit and silently stopped processing jobs. No error. Just nothing.

The fix was straightforward once I found it. I added a resource tracker that logged every open handle and set up a health check that threw an alert if the count exceeded a threshold. That caught it the next time it happened before it went unnoticed for days. It wasn't a new pattern, just one I'd stopped enforcing consistently on larger teams where code review became more about functionality than lifecycle management.

Get the Full Details

The Art of Clean Up: Life Made Neat and Tidy by Ursus Wehrli
The Art of Clean Up: Life Made Neat and Tidy by Ursus Wehrli

What Most People Get Wrong

The biggest mistake isn't forgetting to clean up. It's assuming the cleanup mechanism you're using actually does what you think it does. Finalizers are the worst example of this. In .NET, a finalizer runs on the GC thread, which means exceptions from a finalizer will terminate the process if they're not caught. In Java, finalizers have had a reputation for being slow and unreliable for decades. The language designers themselves have discouraged their use. Yet finalizers still get written into codebases because they seem like a safety net. They're not. They're a last resort that introduces more problems than they solve. If you need to clean up unmanaged resources, implement IDisposable and the dispose pattern properly. Use finalizers only as a fallback for when consumers of your class fail to call Dispose. Even then, the finalizer should just mark the object for cleanup and let the GC handle the rest through a proper mechanism, not do actual work.

Another common failure point is async cleanup. When you're working with async code, especially in ASP.NET or any modern web framework, the request pipeline can complete and the hosting environment can begin shutting down before your async operations finish. If those operations hold resources, you need to register for host shutdown notifications and wait for pending work to complete. I've seen this cause database connection pool exhaustion in several production systems. The connections weren't being released because the request scope ended before the cleanup code ran.

Transaction Cleanup Is Its Own Problem

Database transactions are where cleanup gets genuinely tricky. Rollback isn't just about undoing writes. It's about releasing locks, freeing temporary tables, clearing session state, and notifying any connected services that the transaction was aborted. In a distributed system, this compounds. You might have a saga pattern where multiple microservices participate in a single logical transaction. If service C fails, services A and B need to compensate. Compensation isn't the same as rollback. It's an intentional reverse operation that has to be designed and tested separately. I worked on a system where the compensation logic for a payment service refund was incorrect under a specific edge case involving concurrent refunds, and it took four months of production issues to trace it back to that single path. The rule I've settled on is simple enough that it sounds obvious but is regularly ignored. Every code path that acquires a resource must have a corresponding release path, and that includes exception paths, early returns, and cancellation points. The try-finally or using pattern exists for exactly this reason. If your cleanup logic is duplicated across multiple branches of your code, you've already failed.

The Art of Clean Up: Life Made Neat Tidy - Ex Libris Bookshop
The Art of Clean Up: Life Made Neat Tidy - Ex Libris Bookshop

When Cleanup Code Becomes Technical Debt

Here's a counter-intuitive point that most cleanup guides don't mention. Sometimes the cleanup code itself is the problem. I've seen projects where the resource management layer grew so complex with nested try-finally blocks, custom dispose implementations, and intricate finalizer chains that the cleanup logic was harder to audit than the original code. Adding a new resource type required changes in six different places. A bug in the disposal sequence could leave the system in an inconsistent state that was nearly impossible to reproduce. The solution in those cases was usually to reduce the surface area. Fewer custom cleanup paths, more reliance on standard patterns, and in some cases rewriting the cleanup layer to use a single abstraction instead of ad hoc implementations throughout. This is more work upfront but saves significant debugging time later. The tradeoff is worth it unless the codebase is small enough that manual cleanup is manageable, in which case you probably don't need the complexity.

The Hard Cases Where Cleanup Fails Completely

Sometimes there is no reliable cleanup. Signal handlers in long-running daemons, kernel-level resources that the runtime can't track, and third-party libraries with opaque native code are examples. In these situations the best you can do is document the limitation, monitor for symptoms, and have a restart strategy. I worked on a system that interfaced with a piece of scientific hardware through a proprietary SDK. The SDK allocated native buffers that weren't freed even after the managed wrapper was garbage collected. There was no documented cleanup method. The workaround was to periodically restart the process on a schedule, which introduced its own set of problems around in-flight requests but kept the memory footprint stable. It wasn't elegant. It was the only option given the constraints. If you're in a situation like that, don't pretend the cleanup works. Document why it doesn't, set up monitoring for the resource you're leaking, and plan for the restart or mitigation strategy. Hiding the problem behind optimistic test coverage is how these things turn into production incidents.