Automating RxJS Cleanup: How Halodoc Eliminated Angular Memory Leaks

Web Development Aug 23, 2025

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 takeUntil pattern 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 ngOnDestroy calls: A developer subscribes to an observable in ngOnInit but forgets to implement the OnDestroy lifecycle hook to clean it up.
  • Complex conditional logic: Subscriptions created inside if blocks or complex functions can be easily missed during cleanup.
  • Multiple subscriptions: Manually managing an array of Subscription objects 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:

  • takeUntil Operator: Ensures an observable completes when a specified notifier emits.
  • OnDestroy Notifier Subject: A Subject that emits in ngOnDestroy(), 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:

  1. We create a private Subject called destroy$.
  2. We pipe the takeUntil(this.destroy$) operator onto every subscription within the component.
  3. In ngOnDestroy, we call .next() and .complete() on the destroy$ 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:

  1. Recursively finds all .ts files in a directory.
  2. Skips complex files (e.g., those with multiple classes or an extends clause) for manual review.
  3. For each eligible file, it finds every .subscribe() call.
  4. It programmatically injects .pipe(takeUntil(this.ngUnsubscribe$)) before the subscription.
  5. It intelligently adds all necessary imports (Subject, takeUntil, OnDestroy).
  6. It implements OnDestroy on the class and adds the ngUnsubscribe$ property and the ngOnDestroy() method.
  7. 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 takeUntil is 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), takeUntil is redundant, as they clean themselves up automatically upon completion. Using operators like first() 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

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.

Tags

Muhammad Sibra

Software Engineer Front End 📍 Jakarta, Indonesia