DEV Community

Andreas Mattes
Andreas Mattes

Posted on

Zoneless Angular 22 in production: error reporting, the back button, and a service worker myth

I build a drink-logging PWA on my own: Angular 22, signals everywhere, provideZonelessChangeDetection(), installed from the browser and from Google Play. Over the months I kept a list titled "things zoneless broke". This autumn I checked every entry against the Angular source instead of my own code comments. One survived, one was a myth, one I'm no longer sure about, and the hardest problem on the list was never about zoneless at all.

1. Unhandled HTTP rejections never reach ErrorHandler

A floating promise that rejects with an HttpErrorResponse goes to window's unhandledrejection event. In a zoneless app nothing routes that to Angular's ErrorHandler, so your error tracker's own global handler picks it up. HttpErrorResponse only implements Error, so Sentry titles it "Object captured as promise rejection with keys: ...", which you can neither search nor group.

My fix rewrites the event before it's sent, using the same normaliser the handled path uses:

beforeSend(event, hint) {
    const original = hint?.originalException;
    if (readErrorStatus(original) === undefined) return event; // only HTTP-shaped failures
    const value = event.exception?.values?.[0];
    if (!value) return event;
    const { error, extras } = normalizeError(original);
    value.type = error.name;      // "HttpError"
    value.value = error.message;  // "Http failure response for <url>: 503 Service Unavailable"
    if (extras) event.extra = { ...extras, ...event.extra };
    return event;
},
Enter fullscreen mode Exit fullscreen mode

What I didn't know when I wrote it: Angular has had provideBrowserGlobalErrorListeners() since v20. It listens for error and unhandledrejection on window and hands them to ErrorHandler, which is the framework's answer to exactly this. I haven't switched yet, because Sentry's browser SDK registers its own global handlers by default, and I want to confirm the two don't report every rejection twice. If you're starting fresh, start with the provider.

2. The service worker myth

My app.config.ts said, in a confident comment, that zoneless apps never produce the stability signal registerWhenStable waits for, so registration always burns the full 30-second timeout. Hence registrationStrategy: 'registerImmediately'.

Angular 22's ngswAppInitializer does this:

case 'registerWhenStable':
  readyToRegister = Promise.race([appRef.whenStable(), delayWithTimeout(+args[0])]);
Enter fullscreen mode Exit fullscreen mode

whenStable() in a zoneless app resolves once PendingTasks drains. There's no zone signal involved. The reason in my comment was wrong, and it had been sitting there for three months looking authoritative. Read the source before you write the comment.

I've since moved off registerImmediately as well, to a custom strategy (registrationStrategy also accepts a function that returns an Observable) that registers once the first screen has rendered, 10 seconds at the latest, so a first launch isn't competing with the offline prefetch. I still didn't go back to registerWhenStable: I'd rather not tie offline support to whether every boot task goes idle.

3. Rendering an imperative overlay before opening it

Bottom sheets are created with createComponent, attached to ApplicationRef and appended to body. The sheet's open animation is an effect on an open signal, and it needs the #content template, which only exists after the first render:

this.appRef.attachView(hostRef.hostView);
document.body.appendChild(hostRef.location.nativeElement);
hostRef.changeDetectorRef.detectChanges(); // render once so #content exists
host.open.set(true);                         // now the open effect has something to animate
Enter fullscreen mode Exit fullscreen mode

I filed this under zoneless. I'm no longer convinced: it reads like ordinary effect timing, an effect scheduled before the first render of its own component. If you know which it is, I'd like to hear it.

4. The back gesture, which was the actual hard part

On a phone, the system back gesture should close the open sheet, not navigate the page away. Every sheet pushes a same-URL history entry when it opens and closes itself when that entry is popped:

history.pushState({ ...(history.state ?? {}), bubble: this.sentinelId }, '');
Enter fullscreen mode Exit fullscreen mode

popstate is a window event, so every open sheet hears every pop. Sentinel ids are monotonic, and a sheet acts only if the pop landed below its own entry:

if (state?.bubble != null && state.bubble >= this.sentinelId) return; // not ours
Enter fullscreen mode Exit fullscreen mode

Three edge cases cost real time:

  • A routerLink inside a sheet that also closes it. Popping the sentinel synchronously fires a popstate that the Router reads as the user pressing back, which silently cancels the forward navigation in flight. Wait for the navigation to settle instead: on success the Router's own pushState has superseded the sentinel and there's nothing to pop.
  • One sheet handing off to another in the same change-detection flush. The closing sheet's history.back() ran before the opening sheet had pushed its own entry, so the resulting popstate landed on the new sheet, which closed itself instantly. Deferring the pop to a microtask lets the newcomer push first; the pop then sees it's no longer on top and does nothing.
  • Two sheets closing in the same flush. history.back() is asynchronous, so the second sheet finds the first one's entry still on top. The monotonic id check stops it from mistaking that entry for its own, but it then skips its pop, which leaves one orphaned entry: the next back press does nothing. Not solved yet. The fix I have in mind is to coalesce pending pops into a single history.go(-n).

None of this is zoneless. It's the price of making a web app behave like a native one on Android.

5. What I didn't use: resource()

A signals-first app with no resource() or httpResource at all. The data layer is delta-cursored sync over a WebSocket, an IndexedDB cache and an offline write queue, and resource() models a request, not a replicated store, so the store is hand-written. That's more code to own, and it's a trade, not a win.

What I'd tell myself

Zoneless was the smallest source of trouble on that list. The real lesson was about comments: two of my three "zoneless breakages" were explanations I wrote down before I understood the problem, and a code comment with a confident cause in it is the hardest kind of wrong to notice later.

The app is CheersCount, a drink tracker with a blood-alcohol curve, if you want to see the sheets and the back gesture on a phone.

Top comments (0)