Automating RxJS Cleanup: How Halodoc Eliminated Angular Memory Leaks
A slow website frustrates users and hurts business. For Halodoc, where patients rely on a fast, responsive platform to access healthcare, every millisecond counts. Sluggish performance can directly impact critical business KPIs like Core Web Vitals (LCP, CLS), increase bounce rates, and lower consultation conversions. One of the most common, yet stealthy, causes of this performance degradation is a memory leak.
This article details our journey to systematically hunt down and eradicate RxJS subscription leaks on the Halodoc Website. We'll cover how we diagnosed the problem, implemented a robust new pattern, and built an automated system to prevent these leaks from ever returning.
Our Goals
Through this initiative for the Halodoc Website, we aim to:
- Fix existing memory leaks identified through profiling.
- Adopt the declarative
takeUntilpattern across all components. - Automate leak detection with our custom lint rule.
- Prevent future leaks through strict CI enforcement.
The Silent Killer: Undisposed Subscriptions
In Angular, we frequently leverage RxJS Observables to manage asynchronous operations and data streams. While powerful, these subscriptions can persist in memory even after their host component is destroyed—unless explicitly unsubscribed.
This manual unsubscribe process is a common source of errors. It's easy to forget, especially in complex components with multiple asynchronous streams. Common scenarios where leaks occur include:
- Forgotten
ngOnDestroycalls: A developer subscribes to an observable inngOnInitbut forgets to implement theOnDestroylifecycle hook to clean it up. - Complex conditional logic: Subscriptions created inside
ifblocks or complex functions can be easily missed during cleanup. - Multiple subscriptions: Manually managing an array of
Subscriptionobjects becomes tedious and error-prone as a component grows.
Our Solution: Script + Linting + CI Enforcement
We’re implementing a multi-layered strategy to prevent and eliminate these memory leaks in the Halodoc Website.
Declarative Cleanup with takeUntil: A New Pattern for Subscription Management
Instead of relying on manual unsubscribe() calls across components, we’re standardizing the use of a script-based takeUntil pattern, which provides a cleaner and more maintainable solution.
At its core:
takeUntilOperator: Ensures an observable completes when a specified notifier emits.OnDestroyNotifier Subject: ASubjectthat emits inngOnDestroy(), triggering automatic unsubscription.
The Problem: The Lingering Subscription
RxJS Observables are essential for handling asynchronous data in Angular. When a component subscribes to an Observable, it creates a connection that will keep receiving data. However, if that component is destroyed (e.g., the user navigates to another page), the subscription doesn't automatically die with it.
Unless you explicitly tell it to stop, the subscription will live on in memory, holding references to the old component and its data. This creates a "leak." Across a large codebase like ours, hundreds of these small oversights accumulate into major performance issues, causing UI sluggishness and application instability
Our Solution: Pattern, Automation, and Enforcement
We're rolling out a multi-layered strategy to eliminate these leaks for good.
1. The Best Practice: The takeUntil Pattern
First, we standardized our approach around a declarative and clean pattern using the takeUntil operator, which is widely considered the gold standard for managing subscriptions in Angular.
The Old Manual Approach
Previously, the common pattern was to store each subscription and manually unsubscribe in the ngOnDestroy hook.
This works, but it requires boilerplate and is prone to human error, especially when managing multiple subscriptions.
The New Declarative Approach with takeUntil
Our new pattern is far more reliable. Here’s the core concept:
- We create a private
Subjectcalleddestroy$. - We pipe the
takeUntil(this.destroy$)operator onto every subscription within the component. - In
ngOnDestroy, we call.next()and.complete()on thedestroy$subject.
This single emission from destroy$ acts as a signal, telling all listening subscriptions to complete and clean themselves up automatically.
This pattern is cleaner, less error-prone, and scales beautifully within complex components.
2. The Safety Net: A Proactive Lint Rule
To catch any subscriptions that don't follow the new pattern, we developed a custom ESLint rule.
This linter automatically scans our code during development. It flags any Observable.subscribe() call that is not either assigned to a variable or piped with a self-completing operator like takeUntil or first, providing immediate feedback directly in the IDE.
3. The Automation: Automated Refactoring and Linting
Adopting this pattern is great, but applying it across hundreds of existing files requires automation.
- For New Components: Our configured linter automatically validates any new component. It flags reactive subscriptions that aren't properly piped with
takeUntil, ensuring the pattern is enforced from the start. - For Existing Components: Manually refactoring our entire codebase was not an option. Instead, we wrote a powerful Node.js script to handle the migration in bulk. This script is covered in detail in the "Bonus" section below.
4. The Gatekeeper: CI/CD Enforcement
Finally, to guarantee compliance, we integrated our custom lint rule directly into our Jenkins CI/CD pipeline. If any committed code violates our subscription rules, the build fails. This blocks problematic code from being merged, creating a robust gatekeeper against future memory leaks.
The screenshot below shows our custom "enforce-subscription-cleanup" rule running and catching a leak during the linting stage of our Jenkins build, preventing the faulty code from progressing.
Bonus: Our Bulk Refactoring Script 🤖
To tackle the hundreds of existing components that needed updating, we created a powerful Node.js script. While the full script uses the TypeScript Compiler API for precise and safe code manipulation (which you can find linked below), here is a simplified conceptual example to illustrate the core logic:
The actual script is more sophisticated. It uses the TypeScript Compiler API to traverse the Abstract Syntax Tree (AST) of each file. This gives the script the intelligence to understand the codebase, locate the exact problem areas (unhandled subscriptions), and apply the takeUntil pattern with surgical precision—a much safer and more reliable way to manipulate code than using regular expressions alone.
How it works:
- Recursively finds all
.tsfiles in a directory. - Skips complex files (e.g., those with multiple classes or an
extendsclause) for manual review. - For each eligible file, it finds every
.subscribe()call. - It programmatically injects
.pipe(takeUntil(this.ngUnsubscribe$))before the subscription. - It intelligently adds all necessary imports (
Subject,takeUntil,OnDestroy). - It implements
OnDestroyon the class and adds thengUnsubscribe$property and thengOnDestroy()method. - It overwrites the file with the updated, leak-free code.
Here's the script itself. Save it in your project root as generate-unsubscriber.mjs.
How to Run It
To execute the script, run the following command from your project's root directory, pointing it at your main application source folder.
node --experimental-modules generate-unsubscriber.mjs src/app
Disclaimer: This script performs sweeping changes. Always run tools like this on a separate, version-controlled branch and thoroughly review all automated changes before merging.
When to Be Cautious with takeUntil
While the takeUntil pattern is our standard, there are scenarios where it should be used with care or avoided:
- Long-Lived Services: In root-level Angular services that are designed to live for the entire application lifecycle, using
takeUntilis often unnecessary, as these services are never destroyed. - Single-Emission Observables: For observables that are guaranteed to emit only once and complete (e.g., most HTTP requests via Angular's
HttpClient),takeUntilis redundant, as they clean themselves up automatically upon completion. Using operators likefirst()can be a more semantic choice here.
The Road Ahead
This initiative is about more than just cleaning up code. It's a commitment to long-term performance, reliability, and maintainability. By automating best practices and building safety nets into our workflow, we're ensuring the Halodoc Website stays fast and stable for our users, and our codebase remains a joy to work with for our developers.
Conclusion
Our initiative to eliminate memory leaks on the Halodoc website has been a strategic investment in long-term performance and developer productivity. By moving beyond manual fixes and establishing a robust ecosystem, combining the takeUntil pattern with a custom refactoring script, a proactive ESLint rule, and strict CI/CD enforcement, we have substantially addressed a whole class of memory leak issues. This multi-layered defense not only resolves existing memory leaks but also ensures our application remains reliable, and maintainable as it continues to evolve.
References
- How I Fixed a Memory Leak in My Angular App
- Angular Memory Leak? Here’s How to Diagnose and Fix It — With Demos!
Join us
Scalability, reliability, and maintainability are the three pillars that govern what we build at Halodoc Tech. We are actively looking for engineers at all levels and if solving hard problems with challenging requirements is your forte, please reach out to us with your resumé at careers.india@halodoc.com
About Halodoc
Halodoc is the number one all-around healthcare application in Indonesia. Our mission is to simplify and deliver quality healthcare across Indonesia, from Sabang to Merauke. Since 2016, Halodoc has been improving health literacy in Indonesia by providing user-friendly healthcare communication, education, and information (KIE). In parallel, our ecosystem has expanded to offer a range of services that facilitate convenient access to healthcare, starting with Homecare by Halodoc as a preventive care feature that allows users to conduct health tests privately and securely from the comfort of their homes; My Insurance, which allows users to access the benefits of cashless outpatient services in a more seamless way; Chat with Doctor, which allows users to consult with over 20,000 licensed physicians via chat, video or voice call; and Health Store features that allow users to purchase medicines, supplements and various health products from our network of over 4,900 trusted partner pharmacies. To deliver holistic health solutions in a fully digital way, Halodoc offers Digital Clinic services including Haloskin, a trusted dermatology care platform guided by experienced dermatologists.We are proud to be trusted by global and regional investors, including the Bill & Melinda Gates Foundation, Singtel, UOB Ventures, Allianz, GoJek, Astra, Temasek, and many more. With over USD 100 million raised to date, including our recent Series D, our team is committed to building the best personalized healthcare solutions — and we remain steadfast in our journey to simplify healthcare for all Indonesians.