Migrating the Halodoc Website to Zoneless Angular
Angular's change detection has always depended on Zone.js — a library that monkey-patches every async browser API so the framework knows when to re-render. At Halodoc, our website frontend is a production Angular SSR application serving millions of health-seeking users across Indonesia.
Last year, a slow accumulation of SSR instability, unpredictable server errors, and a growing mismatch between our new Signals-based reactivity model and the Zone.js runtime pushed us to make a decision: migrate fully to zoneless change detection.
The result — a cleaner bundle, a more debuggable codebase, and the complete elimination of a class of hard-to-reproduce SSR failures — is what this post documents.
Every metric in this post is measured directly from our production build. Server benchmarks were run locally via Apache Bench; Lighthouse scores are the median of three consecutive 13.1.0 audits against the live production URL (halodoc.com), using mobile simulation with 4× CPU throttle.
The Trigger — Why Now?
Two things converged at once: Angular 20 promoted provideZonelessChangeDetection() from experimental to stable, and we had already adopted Signals across several modules.
Signals are designed to tell Angular exactly what changed. Zone.js tells Angular that something async happened — go check everything. The more Signals we wrote, the more Zone.js felt philosophically incompatible with our reactivity model.
On the SSR side, zone.js/node patches Node.js built-ins to give Zone visibility into server-side async operations. Version mismatches or cold-start race conditions produced errors like:
- Zone is not defined
- Cannot read properties of undefined (reading 'run')
- ZoneAwarePromise is not a constructor
These errors were often silent — the SSR process would fall back to client-side rendering without logging a clear cause. Eliminating zone.js/node from the server bootstrap wiped out this entire category of bugs.
Why We Migrated
Three concrete goals drove the decision:
- Stable SSR, eliminated a bug class. zone.js/node was the silent cause of unpredictable server-side rendering behavior. Removing it from the server bootstrap ended that entire category of errors.
- Clearer debugging, explicit change detection. "Angular updated because Zone.js caught a setTimeout" is not a debugging story. "Angular updated because this signal changed" is. Zoneless mode gives every UI update a traceable cause.
- Faster runtime, smaller bundle. Zone.js adds up to 36 KB gzipped (approximately 149 KB uncompressed) of JavaScript that monkey-patches native browser APIs on every page load. The size varies depending on which Zone.js optional extras are bundled — in our case we tracked the full distribution. In a Signals-first codebase, this overhead is entirely unnecessary.

Project Scope
To give context on the scale of this work:
The Migration
The migration touched four areas: runtime configuration, browser polyfills, server-side polyfills, and the test suite.
We've packaged the entire process as a Claude Code skill so any team can run the same migration on their own Angular app. Install it from angular-zoneless , then invoke it inside Claude Code:
/angular-zoneless
Always dry-run first on a production codebase:
/angular-zoneless all --dry-run
Example output:
[DRY RUN] Phase 1: Would add provideZonelessChangeDetection() to src/app/app.config.ts
[DRY RUN] Phase 2: 14 components missing ChangeDetectionStrategy.OnPush
[DRY RUN] Phase 3: 7 spec files use fakeAsync — would convert to async/await
[DRY RUN] Phase 4: Would remove 'zone.js' from polyfills.ts and angular.json
No files are touched in dry-run mode. It also runs two analysis scripts upfront that baseline your Zone.js usage and list every component missing OnPush — so you know the full scope before anything is edited.
When ready, run the full migration or scope it to a single phase:
/angular-zoneless all # full migration
/angular-zoneless config # Phase 1 + 4 only — bootstrap config + polyfill cleanup
/angular-zoneless component # Phase 2 only — components + signals
/angular-zoneless spec # Phase 3 only — spec file migration
1. Runtime Configuration (Phase 1)
One provider change in app.config.ts switches the entire application from Zone.js-driven change detection to explicit, signal-driven change detection:
Before: Zone.js-driven (default)
After: Zoneless, explicit change detection
What changed: provideZonelessChangeDetection() tells Angular's scheduler to rely solely on Signals, Observables with the async pipe, and explicit markForCheck() calls — Zone.js no longer triggers the change detection cycle.
The skill detects your Angular version automatically and uses the correct provider: provideZonelessChangeDetection() on Angular 19+, provideExperimentalZonelessChangeDetection() on Angular 18. It stops and warns if your version is below 18.
2. Component Migration (Phase 2)
The skill adds ChangeDetectionStrategy.OnPush to every component that is missing it, replaces NgZone.run() calls with direct signal updates, and replaces ChangeDetectorRef.detectChanges() with markForCheck() or signal-driven equivalents.
3. Test Suite (Phase 3)
The skill adds provideZonelessChangeDetection() to every TestBed configuration and converts fakeAsync/tick patterns to native async/await. It also removes import 'zone.js/testing' from the Karma setup file.
4. Polyfill Cleanup (Phase 4)
The skill removes zone.js from polyfills.ts, zone.js/node from server polyfills (this eliminated our entire class of SSR Zone is not defined errors), and strips the zone.js entry from angular.json.
Rollback Plan
If a critical regression is discovered after deploying zoneless, reverting is a two-minute change:
- In polyfills.ts, restore: import 'zone.js'
- In app.config.ts, replace provideZonelessChangeDetection() with the original empty-provider setup (or simply remove it — Zone.js presence is enough to re-enable Zone-driven detection).
- Move zone.js back to dependencies in package.json.
The migration is fully reversible. No component code, routing logic, or data layer is affected — only the change detection provider and the polyfills file change between zoneless and Zone-driven modes.
The Results: Bundles, SSR & Lighthouse
Bundle Size Savings
Measured from our production build (dist/browser/), excluding Zone.js saves between 36 KB and 149 KB of uncompressed JavaScript (12–29 KB gzipped). The range reflects the full Zone.js distribution size depending on bundle configuration. Native browser APIs now run without any monkey-patching overhead.
SSR Performance (Apache Bench)
Action: Same two-build methodology as Lighthouse. Ran ab against both serves on 127.0.0.1:4001 with -H "Host: www.halodoc.com" to satisfy SSRF check without source patch.
Lighthouse Audit (Production)
Important context before reading the table: The numbers below reflect pre-existing client-side payload issues, a heavy bundle of render-blocking scripts and third-party requests, that exist independently of the zoneless migration. They are not a result of removing Zone.js. We are surfacing them because they are the next optimization frontier, and because no honest engineering post should report only the favorable numbers. A Zone.js-enabled build of the same production URL produces materially similar Performance, FCP, and LCP figures.
Three consecutive Lighthouse 13.1.0 audits (Mobile, 4× CPU throttle) against the live production URL:
Challenges
The core provider swap was straightforward. The real work — and the real risk — lived in three areas that deserved careful attention.
Shared / Common Library Migration
This was our most significant and unexpected challenge. Halodoc maintains a shared component library (common code) that lives outside the main application repository. Several of its components relied implicitly on Zone.js being present — using NgZone.run(), ChangeDetectorRef with Zone-triggered timing, or third-party dependencies that assumed Zone-patched promises.
Because this library is consumed by the main app, leaving it unmodified caused a cascade of failures once we removed Zone.js from the application shell. The symptoms were particularly hard to diagnose because the errors surfaced in the app, not in the library.
The key lessons from this challenge:
• Audit shared dependencies before starting. Map every component in the common library that uses NgZone, ChangeDetectorRef, or any timing-dependent behavior before you touch the main application.
• Signal migration is a prerequisite, not optional. Components in the common library that relied on Zone-triggered change detection had to be migrated to Signals — or, at minimum, refactored to call markForCheck() explicitly — before the main app migration was stable.
• Shared libraries need their own zoneless TestBed config. Tests in the common library also required the same provideZonelessChangeDetection() treatment and async pattern refactoring described above.
• Version-lock the library during migration. We temporarily pinned the common library version during the migration window to prevent unrelated changes from complicating root-cause analysis.
Implicit Zone Dependencies in Third-Party Code
Several third-party libraries (particularly jQuery-era UI components like SlickCarousel) assumed Zone.js had monkey-patched setTimeout and addEventListener. Without Zone, their internal timing broke silently — the component rendered but interactions felt unresponsive or events did not fire at expected times.
The fix was to wrap these libraries in lightweight facade components that own the lifecycle explicitly, rather than relying on Zone to propagate events into Angular's change detection cycle.
Test Suite as a Signal, Not a Burden
Approximately 30% of the spec files in the migrated modules needed non-trivial changes — not just adding provideZonelessChangeDetection(), but replacing fakeAsync/tick patterns with native async/await, and adding explicit fixture.detectChanges() calls.
Initially this felt like overhead, but it proved valuable: tests that had been silently passing due to Zone-triggered automatic change detection were now failing clearly, surfacing components that had real timing assumptions baked in. The test suite functioned as a migration correctness checker.
SSR Cold-Start Race Conditions
During staging, we encountered a class of intermittent failures specific to SSR cold starts — the server bootstrap completing before certain async initializers had resolved. With Zone.js, these races were papered over by Zone's task-queue tracking. Without it, they became visible.
The resolution was explicit application initialization guards using APP_INITIALIZER tokens, ensuring that critical bootstrapping async work completes before the first render. This was a net improvement in correctness, not a regression.
Conclusion
The migration to zoneless Angular was an architectural alignment as much as a performance optimization. Our codebase had been moving toward Signals-driven reactivity for months. removing Zone.js made that mental model consistent end to end. Every UI update now has a traceable cause a signal changed, a markForCheck() was called, or an async pipe resolved.
The outcome that justified the investment on its own was the SSR story. An entire category of intermittent, hard-to-reproduce server-side errors Zone is not defined, Cannot read properties of undefined (reading 'run'), ZoneAwarePromise is not a constructor retired the moment zone.js/node left the server bootstrap.
What surprised us most was the velocity. A migration this deep, change detection, SSR bootstrap, every async boundary, dozens of legacy components, would historically be a multi-sprint effort. Working alongside AI, we landed it in two days. The AI carried the mechanical load: auditing ChangeDetectorRef usage, flagging Zone dependencies in third-party libs, rewriting timer patterns into Signal equivalents, generating SSR diffs in parallel. We held the architectural judgment. Leaner codebase, delivered in a fraction of the calendar time the same work would have demanded a year ago. that is the result we will remember.
References
For further reading on the APIs, concepts, and tools discussed in this migration, check out these official resources:
- Angular Change Detection API– Official documentation on the zoneless provider.
- Angular Signals Guide– Deep dive into Angular's new reactivity primitive.
- Google Core Web Vitals– Understanding FCP, LCP, TBT, and how they impact performance scores.
- Zone.js Repository– The source code and documentation for the Zone.js library.
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 — if solving hard problems with challenging requirements is your forte, please reach out with your résumé 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.