DEV Community

Shalinee Singh
Shalinee Singh

Posted on

Bring Users Back to Where They Left Off After Session Expiry in Angular

You know the drill: someone is deep in a dashboard, tweaking filters, comparing sprints—and the session dies. They log in again and land on a generic home screen. All context gone.

This post walks through a small route retention pattern for Angular SPAs: persist the last meaningful URL when auth fails, then send the user there after a successful login. Explicit logout is handled separately so you do not “restore” a page the user chose to leave.

What we are solving

Scenario Desired behavior
API returns 401 (expired token) Remember current page → login → return
User hits a protected route while logged out Remember intended URL → login → return
User clicks logout Do not restore old page; use normal post-login routing

Storage is sessionStorage, not cookies: tab-scoped, cleared when the tab closes, and separate from auth tokens.

Architecture at a glance

sequenceDiagram
  participant User
  participant App
  participant RouteRetention
  participant API
  participant Login

  User->>App: Working on /app/reports/sprint-42
  App->>API: Request with expired token
  API-->>App: 401 Unauthorized
  App->>RouteRetention: saveReturnUrl()
  App->>App: clear auth session
  App->>User: Redirect to landing/login
  User->>Login: Signs in successfully
  Login->>RouteRetention: hasReturnUrl()?
  RouteRetention-->>Login: yes → /app/reports/sprint-42
  Login->>App: navigateByUrl(saved route)
Enter fullscreen mode Exit fullscreen mode

Three touchpoints cooperate:

  1. RouteRetentionService — save, read, clear, and navigate to the return URL.
  2. HttpInterceptor — on 401, save route before tearing down the session.
  3. AuthGuard — on blocked navigation, save the URL the user tried to open.
  4. Login component — after success, prefer the saved URL over default role-based routing.

The route retention service

A single injectable owns the returnUrl key and exclusion rules.

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';

@Injectable({ providedIn: 'root' })
export class RouteRetentionService {
  private readonly RETURN_URL_KEY = 'returnUrl';

  constructor(private router: Router) {}

  saveReturnUrl(url?: string): void {
    try {
      const urlToSave = url ?? this.getCurrentUrl();

      if (this.shouldExcludeUrl(urlToSave)) {
        return;
      }

      sessionStorage.setItem(this.RETURN_URL_KEY, urlToSave);
    } catch (error) {
      console.error('Failed to save return URL:', error);
    }
  }

  getReturnUrl(): string | null {
    try {
      return sessionStorage.getItem(this.RETURN_URL_KEY);
    } catch (error) {
      return null;
    }
  }

  clearReturnUrl(): void {
    sessionStorage.removeItem(this.RETURN_URL_KEY);
  }

  hasReturnUrl(): boolean {
    const returnUrl = this.getReturnUrl();
    return !!(returnUrl && !this.shouldExcludeUrl(returnUrl));
  }

  async navigateToReturnUrl(defaultRoute = '/'): Promise<boolean> {
    const returnUrl = this.getReturnUrl();

    if (returnUrl && !this.shouldExcludeUrl(returnUrl)) {
      this.clearReturnUrl();
      return this.router.navigateByUrl(returnUrl);
    }

    return this.router.navigateByUrl(defaultRoute);
  }

  private getCurrentUrl(): string {
    return this.router.url;
  }

  private shouldExcludeUrl(url: string): boolean {
    if (!url || url === '/') {
      return true;
    }

    const excludedPatterns = [
      '/login',
      '/reset-password',
      '/forgot-username',
      '/change-password',
      '/404',
      '/login/disabled',
      '/support',
      '/onboarding',
    ];

    return excludedPatterns.some((pattern) => url.startsWith(pattern));
  }
}
Enter fullscreen mode Exit fullscreen mode

Design choices worth calling out

  • saveReturnUrl(url?) — Callers can pass an explicit URL (e.g. from a guard’s state.url) or omit it to use Router.url (handy when a 401 fires while the user is still on the current screen).
  • shouldExcludeUrl — Never stash auth flows, error pages, or onboarding. Avoids redirect loops and weird “return to login” behavior.
  • clearReturnUrl inside navigateToReturnUrl — One-shot use: after a successful restore, the key is gone so a second login does not reuse a stale path.
  • Try/catch around storage — Private mode or disabled storage should not break login; you fall back to default navigation.

Saving the route when the session expires (401)

Centralize session expiry in an HTTP interceptor. On 401 (except login/username-check endpoints), save the route, clear auth state, then send the user to your public entry route.

import { Injectable } from '@angular/core';
import {
  HttpInterceptor,
  HttpRequest,
  HttpHandler,
  HttpErrorResponse,
} from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { throwError } from 'rxjs';
import { Router } from '@angular/router';
import { RouteRetentionService } from './services/route-retention.service';
import { AuthCookieService } from './services/auth-cookie.service';

@Injectable()
export class AppInterceptor implements HttpInterceptor {
  constructor(
    private router: Router,
    private authCookies: AuthCookieService,
    private routeRetention: RouteRetentionService
  ) {}

  intercept(req: HttpRequest<unknown>, next: HttpHandler) {
    return next.handle(req).pipe(
      catchError((err: HttpErrorResponse) => {
        const isLoginAttempt = req.url.includes('/auth/login');
        const isUsernameCheck = req.url.includes('/auth/check-username');

        if (err.status === 401 && !isLoginAttempt && !isUsernameCheck) {
          // User was on a real app screen — remember it
          this.routeRetention.saveReturnUrl();

          this.authCookies.clearAuthCookies();
          this.router.navigate(['/']);

          if (err.error) {
            err.error.message = 'Session expired. Please login again.';
          }
        }

        return throwError(() => err);
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Why saveReturnUrl() with no argument? At the moment the 401 is handled, Router.url still reflects the page the user was viewing. Navigation to / happens afterward.

Why not save tokens in sessionStorage? Only the path goes there. Tokens stay in httpOnly cookies or whatever strategy you already use—never mix “where to go” with “who you are.”

Saving the route when a guard blocks access

If someone bookmarks /app/teams/5/analysis and opens it cold while logged out, the interceptor never ran—you need the guard.

import { Injectable } from '@angular/core';
import {
  CanActivate,
  ActivatedRouteSnapshot,
  RouterStateSnapshot,
  Router,
} from '@angular/router';
import { AppService } from '../app.service';
import { RouteRetentionService } from '../common/services/route-retention.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private router: Router,
    private appService: AppService,
    private routeRetention: RouteRetentionService
  ) {}

  canActivate(_route: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    if (!this.appService.isLoggedIn()) {
      this.routeRetention.saveReturnUrl(state.url);
      this.router.navigate(['login']);
      return false;
    }
    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

Here you pass state.url so query params and nested paths are preserved (e.g. /app/teams/5?tab=git).

Restoring the route after login

On successful login, check for a saved URL before your usual role-based defaults.

private async onLoginSuccess(user: UserSession): Promise<void> {
  const sessionSet = this.appService.setSession(
    user.accessToken,
    user.expiresAt,
    user
  );

  if (!sessionSet) {
    return;
  }

  if (this.routeRetention.hasReturnUrl()) {
    await this.routeRetention.navigateToReturnUrl('/');
    return;
  }

  // No saved route — existing product logic
  if (user.isAdmin) {
    await this.router.navigate(['/admin']);
  } else if (user.isManager) {
    await this.router.navigate(['/app/overview']);
  } else {
    await this.router.navigate(['/app/dev/overview']);
  }
}
Enter fullscreen mode Exit fullscreen mode

hasReturnUrl() reuses the same exclusion rules so a polluted storage key cannot send users to /login.

Apply the same block on any alternate login entry (OAuth, SSO, magic link) so behavior stays consistent.

Clearing retention on intentional logout

When the user logs out on purpose, wipe the saved URL. Otherwise the next login would feel like “resume session” even though they meant to leave.

clearSession(): void {
  this.authCookies.clearAuthCookies();
  this.routeRetention.clearReturnUrl();
  this.currentUser = null;
}
Enter fullscreen mode Exit fullscreen mode

Only call clearReturnUrl from explicit logout—not from the 401 handler (that path needs the saved URL).

End-to-end flows

Session expires mid-work

  1. User on /app/jira/sprint-analysis?board=12.
  2. API returns 401 → interceptor calls saveReturnUrl() (current router URL).
  3. Cookies cleared → navigate to /.
  4. User logs in → navigateToReturnUrl() → back to sprint analysis with query string intact.

Deep link while logged out

  1. User opens /app/teams/3/summary without a session.
  2. Guard saves state.url, sends to login.
  3. After login → same team summary.

Explicit logout then login

  1. User logs out → clearReturnUrl().
  2. User logs in → no saved URL → normal admin/manager/dev home routes.

Security and privacy notes

  • Store paths only, never passwords, tokens, PII, or API keys.
  • Use sessionStorage, not localStorage, if you want retention scoped to the tab/session.
  • Maintain an exclusion list for auth and support routes to prevent open redirects disguised as “return URLs.” For stricter apps, validate saved paths against an allowlist of route prefixes.
  • Do not log full URLs in production if they might contain sensitive query parameters; strip or blocklist query keys if needed.
  • This pattern complements—not replaces—server-side session invalidation and CSRF protections.

Testing checklist

  • [ ] 401 on a nested app route → login → lands on same route + query params.
  • [ ] Direct navigation to protected URL while logged out → login → intended URL.
  • [ ] Logout → login → default home (no restore).
  • [ ] 401 on /login does not overwrite a previously saved return URL (exclude login API).
  • [ ] Saved /login or /reset-password is never used as a return target.
  • [ ] sessionStorage unavailable → login still succeeds with default routing.

Takeaways

  • One small service + three integration points (interceptor, guard, login) give a noticeably better UX after session expiry.
  • Save early (before clearing auth), restore once (then clear the key), exclude auth and error routes.
  • Treat logout differently from expiry so user intent stays clear.

If you wire this up in your own app, start with the service and the 401 interceptor—that covers the most common “I was working and got kicked out” case. Add the guard next for deep links, then hook login last.


Have you shipped something similar—localStorage, router state, or a returnUrl query param? What edge cases bit you? Share in the comments.


🚀 About the Author

Thanks for reading! I'm Shalinee Singh, currently leading SignalsAI. I love building smart, user-centric solutions and sharing patterns that make life easier for developers.

Let's stay connected! You can find me and follow my journey on LinkedIn.

Top comments (0)