What this usually means
The root cause is almost always that the interceptor class is not registered with Angular's dependency injection as a multi-provider for the HTTP_INTERCEPTORS injection token. Unlike services that use a single provider, interceptors must be registered with { provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }. Forgetting multi: true causes Angular to replace the entire interceptor chain with just that one, breaking any other interceptors—and if the interceptor itself fails to instantiate, the chain collapses silently. Other subtle causes include: the module or standalone component that provides the interceptor is not imported in the root module or bootstrap; the interceptor is provided in a lazy-loaded module that never loads; or the interceptor's constructor throws an error (e.g., a dependency injection failure), causing Angular to skip it without a visible error. In AOT builds, tree-shaking can remove unused providers if the interceptor is not referenced transitively. Finally, the interceptor may be filtering requests by URL or method and matching none.
The first ten minutes — establish facts before touching code.
- 1Open browser DevTools console: run `ng.profiler.timeChangeDetection()` to confirm Angular is running. If no errors, proceed.
- 2Set a breakpoint in the interceptor's intercept() method. Reload. If never hit, add a breakpoint in the constructor. If constructor never called → provider issue.
- 3Check provider registration: grep for 'HTTP_INTERCEPTORS' in your AppModule (or app.config.ts). Verify `multi: true` is present.
- 4Run `ng serve --source-map` and in console type `ng.probe(getAllAngularRootElements()[0]).injector.get(HTTP_INTERCEPTORS)` to see if your interceptor is in the chain.
- 5If using standalone APIs, confirm the interceptor is listed in `provideHttpClient(withInterceptors([...]))` not withInterceptorsFromDi() alone.
- 6In production build, add `--optimization=false` temporarily to rule out AOT tree-shaking.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchsrc/app/app.module.ts or src/app/app.config.ts – provider registration for HTTP_INTERCEPTORS
- searchAny lazy-loaded module's module.ts – if interceptor is provided there, it only intercepts requests from that module
- searchinterceptor.ts file – constructor and intercept() method signature, verify it returns `next.handle(req)`
- searchpackage.json – @angular/common/http version (interceptor API changed in v15 with standalone)
- searchangular.json – optimization settings (if set to true, tree-shaking might remove unused classes)
- searchBrowser DevTools > Sources > Page – search for your interceptor class name to see if it's in the bundle
- searchNetwork tab – inspect request headers; if interceptor adds headers, missing headers confirm it didn't fire
Practical causes, not theory. These are the things you will actually find.
- warningProvider registered without `multi: true` – HTTP_INTERCEPTORS becomes a single provider, breaking all interceptors
- warningInterceptor provided in a lazy-loaded module that is never loaded
- warningInterceptor constructor throws an error (e.g., missing dependency) – Angular swallows it silently
- warningInterceptor uses `@Injectable({ providedIn: 'root' })` but also registered as a provider – double registration may cause unexpected behavior
- warningAOT tree-shaking removes the interceptor class because it's not referenced in any template
- warningUsing `withInterceptorsFromDi()` but forgetting to import `HttpClientModule`
- warningInterceptor filters requests by URL or HTTP method and matches none (e.g., checks for 'api' but all requests go to '/graphql')
Concrete fix directions. Pick the one that matches your root cause.
- buildAdd `multi: true` to the provider: `{ provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }`
- buildMove provider to AppModule or root app.config if using standalone; remove from lazy modules unless intentional
- buildWrap constructor body in try-catch and log errors to find DI failures
- buildFor standalone: use `provideHttpClient(withInterceptors([myInterceptorFn]))` for functional interceptors
- buildEnsure the interceptor is imported and added to the `providers` array in the correct module
- buildAdd a default export or reference the interceptor class in a module's `entryComponents` (deprecated but works) to prevent tree-shaking
A fix you cannot prove is a guess. Close the loop.
- verifiedConsole.log inside intercept() with a unique string – reload and check console
- verifiedInspect request headers in Network tab to confirm interceptor-added headers appear
- verifiedRun `ng build --stats-json` and examine the `stats.json` for the interceptor class – if missing, tree-shaking is the culprit
- verifiedAdd a breakpoint in intercept() and step through: ensure `next.handle(req)` is returned
- verifiedWrite a unit test: `TestBed.configureTestingModule({ providers: [{ provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }] })` and verify it intercepts
- verifiedUse Angular DevTools to inspect injector tree: find the route where interceptor is supposed to be active
Things that make this bug worse or harder to find.
- warningAdding `providedIn: 'root'` to the interceptor AND registering it as a provider – Angular may create two instances or none
- warningForgetting to return `next.handle(req)` – the request will hang without error
- warningAssuming all interceptors run in order – they execute in the order provided, so a previous interceptor may short-circuit
- warningUsing `withInterceptorsFromDi()` without importing `HttpClientModule` in standalone apps
- warningProviding the interceptor in a shared module that is imported in both root and lazy modules – may cause multiple instances
- warningRelying on `providedIn: 'any'` – not supported for interceptors; must use HTTP_INTERCEPTORS token
Auth Interceptor Silent in Production Build
Timeline
- 09:15Deploy to production after adding AuthInterceptor to attach JWT token.
- 09:20User reports 401 errors on API calls; no Authorization header in requests.
- 09:25I check local dev: interceptor works fine. Console.log fires, headers attached.
- 09:30I open production site in incognito, check Network tab – no Authorization header on any XHR.
- 09:35I add a breakpoint in interceptor constructor via DevTools source maps – breakpoint never hit.
- 09:40I check app.module.ts: provider is `{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor }` – missing `multi: true`.
- 09:45Fix: add `multi: true`. Rebuild, redeploy. Requests now have Authorization header.
- 09:50Users confirm 401 errors resolved. I set up a unit test to catch missing multi:true in CI.
We rolled out a new auth flow that required attaching a JWT to every API call. I wrote an AuthInterceptor in minutes, tested it locally with `ng serve` – perfect. Console.log printed, headers attached. I merged and deployed to production. Ten minutes later, the NOC pinged me: all API calls returning 401. Users were locked out.
I opened the production site, checked Network tab – no Authorization header. But the interceptor worked in dev. I added a breakpoint in the interceptor constructor via browser DevTools source maps – it never hit. That meant the interceptor was never instantiated. I checked the production bundle and saw the class was present, so it wasn't tree-shaken. Then I looked at the provider registration: `{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor }`. No `multi: true`. In dev mode, Angular's development checks might have tolerated it partially, but in production, the interceptor chain broke entirely.
I added `multi: true`, rebuilt, redeployed. Instant fix. The lesson: the `multi: true` flag is not optional – without it, Angular treats the provider as a single value override, replacing the entire interceptor chain. Our production build had only that one interceptor (missing multi), so the chain became null effectively. Now I have a unit test that verifies the interceptor array contains our class and that `multi` is set.
Root cause
Provider registration missing `multi: true` for HTTP_INTERCEPTORS token.
The fix
Changed `{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor }` to `{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }`.
The lesson
Always include `multi: true` when providing HTTP_INTERCEPTORS. Add a unit test that asserts the provider exists with multi flag. Never trust dev-only behavior for production deployments.
Angular's HTTP client uses the `HTTP_INTERCEPTORS` injection token to collect all interceptors into an ordered array. The `HttpInterceptorHandler` chain is built by injecting `HTTP_INTERCEPTORS` as `@Inject(HTTP_INTERCEPTORS) interceptors: HttpInterceptor[]`. If any provider is registered without `multi: true`, Angular's DI replaces the token value with that single provider, destroying the array. The result: only the last registered non-multi interceptor survives, or if the non-multi interceptor fails to instantiate, the array becomes empty.
This is a common pitfall because most Angular providers are singular (e.g., a service). The `multi` flag tells Angular to collect providers into an array rather than override. Many developers forget it because documentation shows it but code completion doesn't enforce it. A quick grep for `HTTP_INTERCEPTORS` in your project will reveal missing `multi: true`.
If you provide an interceptor in a lazy-loaded module's `providers` array, that interceptor only intercepts HTTP requests made by components and services within that module (and its children). Angular creates a child injector for each lazy module. The root `HttpClient` uses the root injector's `HTTP_INTERCEPTORS` array. So interceptors provided in lazy modules are invisible to the root injector. This is intentional – it allows feature modules to add interceptors only for their own requests, but it often surprises developers who expect global interception.
The fix: either provide the interceptor in the root AppModule (or `app.config.ts` for standalone), or import the lazy module eagerly. Alternatively, you can use `providedIn: 'root'` on the interceptor class and still register it with `multi: true` in the root – the `providedIn` only ensures the class itself can be injected, but the token registration is what adds it to the chain.
Angular's production builds use Ahead-of-Time (AOT) compilation and tree-shaking to remove dead code. An interceptor class that is only referenced as a provider token (via `useClass`) may be considered unused if the token itself is not referenced by any component's constructor. Angular's compiler tracks dependencies through `@Injectable()` and constructor injection. If the interceptor is not injected anywhere directly (only registered as a provider for `HTTP_INTERCEPTORS`), the compiler might not see the dependency and tree-shaking may remove the class.
To prevent this, ensure the interceptor is referenced at least once in a way the compiler can trace. Common techniques: add the interceptor to an `entryComponents` array (Angular 9+ deprecated, but still works), or import the interceptor class in the module file and use it in a factory provider. The safest approach is to use `useExisting` with a class that has `providedIn: 'root'` – but that's complex. Simpler: add a dummy `@Injectable()` and inject the interceptor into a root service that is never used? That's hacky. What actually works: keep the provider registration in the root module and ensure the module is eagerly loaded. Angular's compiler then includes the class because it's referenced in the module's provider array.
Angular 15 introduced standalone APIs that change how interceptors are registered. `provideHttpClient(withInterceptorsFromDi())` enables the legacy DI-based interceptor chain (i.e., `HTTP_INTERCEPTORS` token). `provideHttpClient(withInterceptors([myInterceptorFn]))` uses functional interceptors, which are simpler and recommended for new code. Mixing both can cause confusion: if you use `withInterceptorsFromDi()`, you must still register DI interceptors via `providers: [{ provide: HTTP_INTERCEPTORS, useClass: ..., multi: true }]`. If you use `withInterceptors()`, you pass an array of functions directly, and `HTTP_INTERCEPTORS` is ignored.
A common mistake: using `withInterceptorsFromDi()` but forgetting to include the interceptor in the providers array, or using `withInterceptors()` but passing a class instead of a function. Functional interceptors have a different signature: `(req: HttpRequest<unknown>, next: HttpHandlerFn) => Observable<HttpEvent<unknown>>`. If you accidentally pass a class, it won't be called and no error is thrown.
If an interceptor's constructor throws an error (e.g., a required dependency is missing), Angular's DI catches the error and does not add the interceptor to the chain. Worse, it doesn't log the error by default (in production mode). The interceptor simply disappears. This is because `HTTP_INTERCEPTORS` is a multi-provider, and each provider is resolved independently. A failure in one does not block others, but it also does not report the failure.
To debug: add a try-catch in the constructor and log to console. Better: use Angular's `ErrorHandler` to catch all DI errors. You can also temporarily set `providedIn: 'root'` and inject the interceptor into a component to see if it instantiates. If the constructor throws outside the interceptor chain, you'll see the error. Another approach: run `ng serve` with `--prod=false` (dev mode) which gives more verbose DI error messages.
Frequently asked questions
My interceptor works in dev but not in production. What's different?
Most likely AOT compilation and tree-shaking. In dev mode, Angular uses JIT and includes all classes. In production, AOT removes unused code. Ensure your interceptor is referenced in a way the compiler traces – e.g., registered in an eagerly loaded module's providers. Also check for missing `multi: true` – dev mode may tolerate it partially, but production breaks. Run `ng build --optimization=false` to test if optimization is the cause.
I have multiple interceptors but only one doesn't fire. Why?
If one interceptor's constructor throws, it is silently skipped while others continue. Check that interceptor's constructor for DI errors. Also verify the order of providers – if you accidentally registered another interceptor without `multi: true` after it, that non-multi provider overrides the entire chain. Use Angular DevTools to inspect the `HTTP_INTERCEPTORS` token value.
Does `providedIn: 'root'` on my interceptor class replace the need for HTTP_INTERCEPTORS provider?
No. `providedIn: 'root'` makes the class injectable anywhere, but it does not automatically add it to the interceptor chain. You still must register it as `{ provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }`. However, if you also have `providedIn: 'root'`, Angular might create an extra instance if you inject the class elsewhere. It's recommended to omit `providedIn` and rely solely on the provider registration.
In standalone app, how do I register a class-based interceptor?
Use `provideHttpClient(withInterceptorsFromDi())` and then add the interceptor to the providers array: `providers: [{ provide: HTTP_INTERCEPTORS, useClass: MyInterceptor, multi: true }]`. Alternatively, convert your interceptor to a function and use `withInterceptors([myInterceptorFn])`. Functional interceptors are simpler for standalone apps.
My interceptor adds headers but they don't appear in the request. What else could block it?
Check if another interceptor earlier in the chain clones the request and discards headers. Also ensure you are returning `next.handle(clonedReq)` not `next.handle(req)`. If the interceptor uses `HttpRequest.clone()` incorrectly (e.g., mutating the setHeaders object), the clone might not apply. Verify with console.log of the cloned request's headers.