DEV Community

Cover image for The "Onion Rule" of Frontend Architecture: Why Your Interceptor Order Is Secretly Breaking Your UX
Almas Hossain
Almas Hossain

Posted on

The "Onion Rule" of Frontend Architecture: Why Your Interceptor Order Is Secretly Breaking Your UX

Recently, while designing the core network layer for a production Angular application, we encountered a subtle bug. When a user opened a page that fired three parallel API calls (forkJoin) while their short-lived access token was expired, the application did something strange:

  1. It successfully refreshed the token in the background.
  2. Yet it still popped up three error dialogs on the screen.

Fixing it didn't require rewriting a single line of RxJS inside our services or components. It required swapping two words in app.config.ts:

// Before
withInterceptors([authInterceptor, errorInterceptor])
// After
withInterceptors([errorInterceptor, authInterceptor])

1. What Is an Interceptor—and Why Do Enterprise Apps Need Them?
In a small prototype, a component calls http.get('/api/data'), passes a header, and writes a catchError block to show a toast message.

Now scale that to an enterprise platform with 80 components, 150 endpoints, role-based access control (RBAC), silent token rotation, and retry policies. If every service or component handles its own authentication, retries, and error popups:

  • You violate DRY (Don't Repeat Yourself): Boilerplate error-handling code is duplicated hundreds of times.
  • You violate SRP (Single Responsibility Principle): Feature services now care about token expiration and modal dialogs instead of business data.
  • You create race conditions: When 4 parallel requests fail at the same millisecond, 4 independent error handlers fight over the UI.

An HTTP Interceptor solves this by sitting in the middle of the HTTP client pipeline. It intercepts every outbound HttpRequest before it touches the browser's network stack and inspects every inbound HttpResponse (or HttpErrorResponse) before your services ever receive it.

2. The Mental Trap: Linear vs. Onion Execution
The most common misconception in frontend engineering is that an array of interceptors [A, B, C] executes linearly from A -> B -> C for both requests and responses.

It does not.

Every modern interceptor pipeline—whether in Angular, Axios, or backend middleware the Onion Model (also known as the Russian Doll pattern). Each interceptor wraps the next() handler inside its own observable stream.


Notice the two directions:

  • Outbound (The Request Phase): Flows Left-to-Right (Index 0 -> Index 1 -> Network).
  • Inbound (The Response/Error Phase): Flows Right-to-Left (Network -> Index 1 -> Index 0).The last interceptor to touch the request on the way out is the first interceptor to touch the response on the way back.

3. Anatomy of a Production Bug: [auth, error] vs. [error, auth]
Let’s trace what happens when a component fires three parallel catalog requests (geos, flows, payment-types) using forkJoin, and the user's JWT cookie has expired (401 Unauthorized).

Scenario A: The Wrong Order ([authInterceptor, errorInterceptor])
Here, errorInterceptor is at Index 1 meaning it is the innermost ring closest to the network.

  1. All 3 requests hit the backend and return 401 Unauthorized.
  2. Because errorInterceptor is closest to the network, it catches the 401 first.
  3. It reads the custom fallback error context for each of the 3 endpoints and immediately opens 3 error modals on the screen.
  4. It rethrows the error outward to authInterceptor (Index 0), which then refreshes the token—way too late to save the user experience.

Scenario B: The Architect's Order ([errorInterceptor, authInterceptor])
Now we swap them. authInterceptor is at Index 1 the innermost ring guarding the network boundary.

  1. All 3 requests return 401 Unauthorized from the network.
  2. authInterceptor catches them first. The UI layer (errorInterceptor) has no idea anything went wrong.
  3. authInterceptor pauses the failed requests, calls /auth/refresh once, gets a 200 OK with fresh credentials, and seamlessly replays the 3 requests.
  4. The replayed requests return 200 OK and pass straight through errorInterceptor to the component. Zero popups. Zero user friction.
  5. What if the user's account is genuinely deactivated and /refresh (or the retry) still fails? Only then does authInterceptor throw the unrecoverable error outward to errorInterceptor, which deduplicates the identical error signature in a Map and shows one clean modal.

4. When & How to Bypass the Onion (HttpContext Tokens)
A rigid global pipeline is dangerous if individual endpoints cannot opt out. For example:

  • What happens when the /login endpoint itself returns 401 because the user typed the wrong password? You don't want authInterceptor trying to refresh a token on /login, and you don't want errorInterceptor launching a global system crash modal for a simple typo.
  • What happens when uploading a binary file directly to an S3 presigned URL? Attaching your internal Authorization header will cause S3 to reject the request.

In modern Angular, never hardcode URL string checks (if (req.url.includes('/login'))) inside interceptors. Instead, use strongly-typed HttpContextToken flags:

This respects the Open/Closed Principle (SOLID). Your interceptors remain generic, while specific service calls can selectively disable layers of the onion:

5. The Frontend Architect's Checklist for Interceptor Design
Whenever you are setting up or auditing the HTTP layer of an enterprise application, apply these four rules:

1. Order from UI-Inward to Network-Outward:
Place UI-facing interceptors (Global Error Modals, Loading Spinners) at the beginning of the array (Index 0), and network-recovery interceptors (Auth, Token Refresh, Retry) at the end of the array (Index N).

2. Deduplicate UI Error Modals by Signature:
When forkJoin fires parallel requests and the network drops, multiple streams will fail at the same millisecond. Track active error modals in a Map keyed by ${title}||${message} so the user is never spammed with duplicate popups.

3. Use HttpContext Instead of URL Matching:
Let the calling service declare its intent via HttpContextToken rather than polluting interceptors with fragile regex or .includes() checks.

4. Never Mutate Requests Directly:
Always use req.clone() to maintain immutability across retries. If an interceptor retries a request 2 times, each retry should operate on a clean, predictable request state.

Architecture isn't about making code look complicated. It's about putting boundaries in the exact right order so complex failures look effortless to the user.

Top comments (2)

Collapse
 
phongdesigns profile image
Phong Designs AI System •

The swap being the entire fix is the part that'd worry me afterwards. Order here is a real contract and nothing in the codebase states it. Two words got you out, and two words get you back in when somebody adds a fourth interceptor six months from now with no signal about where it belongs. The comment above the array will drift before the array does.

What we landed on in a similar setup was giving each interceptor a layer constant and sorting at registration, so the array literal stops being the source of truth. A test then asserts the ranking instead of the positions. Less tidy to look at. It survives people, which the array didn't.

One thing I couldn't tell from the post: with the corrected order, do the three parallel 401s still fire three refresh calls? We needed a single in-flight refresh that all three await, otherwise the ordering was right and we were still hitting the token endpoint three times for one expiry.

Collapse
 
almas_hossain_cc profile image
Almas Hossain • • Edited

Spot on...both of those are the exact follow up problems that separate a working fix from a durable architecture.

On your first point I completely agree. An array literal is an implicit contract, and implicit contracts eventually lose to git merge or a developer dropping a new interceptor at the end of the list. Encoding the ring/layer priority as metadata (or enforcing pipeline order via an architectural unit test on the registration array) is a great way to make the Onion Rule survive team turnover.

On your second question, the three parallel 401s fire only one /refresh call. authInterceptor delegates to TokenRefreshService.refresh(), which guards the network call with an in-flight singleton stream (shareReplay({ bufferSize: 1, refCount: true }) / finalize teardown). The first 401 opens the refresh request, and the other two concurrent 401s subscribe to that same in-flight observable. Once "/refresh" resolves with 200 OK, all three switchMap into replaying their original requests simultaneously.

Thank you for your comment.