NgRx Effect behavior during concurrent 401 Unauthorized responses
0 reputation · 30 Jul 2022, 19:31 UTC
0 reputation · 30 Jul 2022, 19:31 UTC
In an Angular application using NgRx, the standard pattern for handling expired credentials involves an HTTP Interceptor that dispatches a logout action upon receiving a 401 Unauthorized response. This action is then handled by an NgRx Effect to clear the store and redirect the user.
When multiple concurrent API requests fail simultaneously due to token expiration, the interceptor may dispatch multiple identical logout actions in rapid succession. This creates uncertainty regarding the execution flow of the associated Effect, as the store state may be cleared by the first action while subsequent actions are still in the pipeline.
Which NgRx Effect operator is most effective for suppressing duplicate logout sequences caused by concurrent 401 errors? How can the state be synchronized to ensure only one authentication reset occurs regardless of the number of failing requests?
33775 reputation · 30 Jul 2022, 23:06 UTC
Use exhaustMap in the logout Effect. When several 401 responses arrive at once and the interceptor dispatches multiple identical logout actions, exhaustMap ignores every new action until the in-flight logout sequence (clear credentials, navigate) completes. Pair that with a single reducer that resets the auth state slice atomically, and exactly one logout sequence runs no matter how many requests fail simultaneously.
mergeMap runs every dispatched logout action concurrently, so you get multiple navigation calls and repeated redirect cycles.switchMap cancels the previous sequence and restarts on each new action. With rapid-fire 401s this can abort a half-finished logout and trigger the redirect logic repeatedly.concatMap queues the duplicates, so the redirect still executes once per action — just sequentially.exhaustMap drops duplicates while one sequence is active, which matches the semantics of "logout is already happening, ignore further requests to log out."Keep the interceptor dumb: it only dispatches the action. The reducer handles the synchronous, atomic state reset; the Effect handles the asynchronous side effects.
// reducer: atomic reset, runs once per dispatched action but is idempotent
on(AuthActions.logout, () => initialAuthState)logout$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.logout),
exhaustMap(() =>
this.authApi.revokeSession().pipe(
catchError(() => of(null)), // local logout must succeed even if the API call fails
),
),
tap(() => this.router.navigate(['/login'])),
),
{ dispatch: false },
);Two details matter:
exhaustMap only blocks while the inner observable is alive. If your inner stream never completes (or errors without a catchError), either every subsequent logout is swallowed forever, or the Effect stream dies. Always terminate the error path inside the exhaustMap.If you want to cut noise earlier, the interceptor can check a store selector (e.g., selectIsAuthenticated) before dispatching, skipping the logout action when the user is already logged out. This is a complement, not a replacement — the exhaustMap in the Effect remains the authoritative guard for the redirect.
Router.navigate in a test).logout action produces the same state — one meaningful transition, no flicker.exhaustMap inner observable completed properly).This guidance assumes a current NgRx version (13+); the operator semantics described are stable RxJS behavior and apply broadly.
Use comments to ask for clarification. Post a solution as an answer.
33,775 reputation · 30 Jul 2022, 21:31 UTC
Add a boolean flag isLogoutPending to the auth slice and guard the interceptor dispatch with a filter that checks this flag before emitting a logout action. The Effect then uses exhaustMap to ensure only one logout sequence runs, while the reducer clears the state atomically.
// auth slice
state: {
credentials: {...},
isLogoutPending: false,
}
// interceptor
if (error.status === 401 && !state.isLogoutPending) {
store.dispatch(AuthActions.logout());
state.isLogoutPending = true;
}
// effect
logout$ = createEffect(() =>
this.actions$.pipe(
ofType(AuthActions.logout),
filter(() => !this.authState.isLogoutPending), // guard
exhaustMap(() => this.authApi.revokeSession()),
tap(() => {
this.authState.isLogoutPending = false;
this.router.navigate(['/login']);
})
)
);