DEV Community

Rajat
Rajat

Posted on

From 5MB to 500KB: The Hidden Cost of providedIn: 'root' in Angular – A Performance Deep Dive

How Angular's Tree-Shaking Actually Works (And How to Keep Your Bundle Lean in 2025)


Ever shipped an Angular app and wondered why your main bundle is still bloated even after using providedIn: 'root' everywhere?

Here's a question for you: Do you actually know where your services end up in production?

If you're like most Angular devs (myself included, until recently), you probably assumed providedIn: 'root' magically optimizes everything. Well, buckle up—we're about to uncover some surprising truths about Angular's dependency injection that could save your app 30-50% in bundle size.

By the end of this article, you'll know:

  • The real truth about where providedIn: 'root' services live
  • When tree-shaking actually works (and when it fails spectacularly)
  • How to optimize your services for a lightweight global context
  • Modern Angular patterns that actually make a difference

Let's dive in!


The Myth We All Believed

Quick poll: How many of you thought providedIn: 'root' automatically meant "optimized and tree-shakeable"?

raises hand

Yeah, me too. But here's what's actually happening under the hood...

// Angular  syntax - What we typically write
import { Injectable, inject } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  private readonly http = inject(HttpClient);

  getUser(id: string) {
    return this.http.get(`/api/users/${id}`);
  }
}

Enter fullscreen mode Exit fullscreen mode

Looks clean, right? But here's the kicker: This service might still end up in your main bundle even if it's only used in a lazy-loaded module!

💬 Have you ever analyzed your bundle and found services where they shouldn't be? Drop a comment—I'm curious how common this is!


The Good, The Bad, and The Bundle Size

✅ The Good: When providedIn: 'root' Shines

Let's be clear—providedIn: 'root' isn't evil. It's actually brilliant when used correctly:

// Angular  - Perfect use case for root-provided service
import { Injectable, signal, computed } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ThemeService {
  // Using signals (Angular 16+)
  private readonly isDarkMode = signal(false);

  readonly theme = computed(() =>
    this.isDarkMode() ? 'dark' : 'light'
  );

  toggleTheme() {
    this.isDarkMode.update(current => !current);
  }
}

Enter fullscreen mode Exit fullscreen mode

Why this works: Global state that's genuinely needed everywhere = perfect for root injection.

❌ The Bad: When Things Go Wrong

Here's where developers get burned:

// This looks innocent but can bloat your main bundle
@Injectable({
  providedIn: 'root'
})
export class HeavyAnalyticsService {
  private readonly analytics = inject(AnalyticsLibrary); // 200KB library
  private readonly featureFlags = inject(FeatureFlagService);
  private readonly logger = inject(LoggingService);

  // Only used in admin module...
  trackAdminAction(action: string) {
    // Complex tracking logic
  }
}

Enter fullscreen mode Exit fullscreen mode

Even if this service is only imported in a lazy-loaded admin module, the entire chain of dependencies might get pulled into your main bundle.

The result? Your users download analytics code they'll never use. Ouch.


The Solution: Smart Service Architecture in Angular

Let me show you three patterns that actually work in production:

Pattern 1: Module-Scoped Services (The Classic)

// feature/admin/services/admin-analytics.service.ts
import { Injectable, inject } from '@angular/core';

@Injectable() // No providedIn!
export class AdminAnalyticsService {
  private readonly analytics = inject(AnalyticsLibrary);

  trackAdminAction(action: string) {
    // Heavy logic stays in the admin module
  }
}

// feature/admin/admin.module.ts
@NgModule({
  providers: [AdminAnalyticsService] // Provided at module level
})
export class AdminModule { }

Enter fullscreen mode Exit fullscreen mode

Pattern 2: Lazy-Injectable Pattern (My Personal Favorite) 🎯

// Angular  with standalone components
import { Injectable, inject } from '@angular/core';

@Injectable({
  providedIn: 'any' // Creates separate instance per lazy module
})
export class FeatureStateService {
  private readonly state = signal<FeatureState>(initialState);

  // Each lazy module gets its own instance
  updateState(updates: Partial<FeatureState>) {
    this.state.update(current => ({ ...current, ...updates }));
  }
}

Enter fullscreen mode Exit fullscreen mode

Pattern 3: Token-Based Injection (For Ultimate Control)

// Angular - Using injection tokens for flexibility
import { InjectionToken, inject } from '@angular/core';

export interface Logger {
  log(message: string): void;
}

export const LOGGER_TOKEN = new InjectionToken<Logger>('Logger');

// In your main.ts
bootstrapApplication(AppComponent, {
  providers: [
    {
      provide: LOGGER_TOKEN,
      useFactory: () => {
        // Only load heavy logger in development
        return environment.production
          ? new SimpleLogger()
          : new DetailedLogger();
      }
    }
  ]
});

// Usage in any component
export class MyComponent {
  private readonly logger = inject(LOGGER_TOKEN);
}

Enter fullscreen mode Exit fullscreen mode

👏 If these patterns just saved you from a refactoring nightmare, hit that clap button! Your future self will thank you.


Let's Test It! Writing Unit Tests in Angular

Here's something most articles skip—how do you actually test these optimized services?

// user.service.spec.ts - Angular testing syntax
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';

describe('UserService', () => {
  let service: UserService;
  let httpTesting: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        provideHttpClient(),
        provideHttpClientTesting(),
        UserService
      ]
    });

    service = TestBed.inject(UserService);
    httpTesting = TestBed.inject(HttpTestingController);
  });

  it('should fetch user with correct endpoint', () => {
    const mockUser = { id: '123', name: 'Dev' };

    service.getUser('123').subscribe(user => {
      expect(user).toEqual(mockUser);
    });

    const req = httpTesting.expectOne('/api/users/123');
    expect(req.request.method).toBe('GET');
    req.flush(mockUser);
  });

  afterEach(() => {
    httpTesting.verify();
  });
});

Enter fullscreen mode Exit fullscreen mode

💡 Pro Tip: Always test your services in isolation first, then test them within their lazy-loaded context to ensure proper scoping!


🎯 Bonus Tips: Bundle Optimization Tricks

1. Use Bundle Analyzer (Your New Best Friend)

# Install webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer

# Add to angular.json
"build": {
  "options": {
    "statsJson": true
  }
}

# Analyze your bundle
ng build --stats-json
npx webpack-bundle-analyzer dist/your-app/stats.json

Enter fullscreen mode Exit fullscreen mode

2. The "Import Cost" VS Code Extension

Install it. Use it. Love it. It shows you the size of every import in real-time. Mind = blown.

3. Lazy Load Everything That's Not Critical

// Angular - Standalone component lazy loading
const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () => import('./admin/admin.component')
      .then(m => m.AdminComponent),
    providers: [AdminService] // Service only loads with component!
  }
];

Enter fullscreen mode Exit fullscreen mode

4. Use Dynamic Imports for Heavy Libraries

// Instead of this
import * as Chart from 'chart.js';

// Do this
async loadChart() {
  const { Chart } = await import('chart.js');
  return new Chart(/* ... */);
}

Enter fullscreen mode Exit fullscreen mode

📊 Quick Recap: Your Action Plan

Let's wrap this up with what you can do right now:

  1. Audit your services: Run bundle analyzer today. Find services in wrong bundles.
  2. Question every providedIn: 'root': Does it really need to be global?
  3. Use providedIn: 'any' for feature-specific services
  4. Leverage injection tokens for swappable implementations
  5. Test your lazy loading: Ensure services stay where they belong

The golden rule: If a service is only used in a lazy module, it shouldn't be in your main bundle. Period.


🚀 Let's Keep This Conversation Going!

Alright, dev to dev—this stuff matters. Your users on slow connections will thank you, and your Lighthouse scores will love you.

💬 Your turn: What's the biggest bundle size win you've achieved? Got a horror story about a 5MB main bundle? Drop it in the comments—let's learn from each other's pain!

👏 Found this helpful? Smash that clap button (you can hit it up to 50 times, just saying 😉). Every clap helps other devs discover these optimization tricks.

📬 Want more Angular performance tips? I drop one actionable optimization technique every week. [Follow me here] to join 5,000+ devs leveling up their Angular game.

🎯 Action challenge: Analyze your main bundle this week and share your before/after stats in the comments. Best improvement gets a shoutout in my next article!

Remember: Every KB you shave off is a user who doesn't bounce. Let's build faster apps together! 🚀


🎯 Your Turn, Devs!

👀 Did this article spark new ideas or help solve a real problem?

💬 I'd love to hear about it!

✅ Are you already using this technique in your Angular or frontend project?

🧠 Got questions, doubts, or your own twist on the approach?

Drop them in the comments below — let’s learn together!


🙌 Let’s Grow Together!

If this article added value to your dev journey:

🔁 Share it with your team, tech friends, or community — you never know who might need it right now.

📌 Save it for later and revisit as a quick reference.


🚀 Follow Me for More Angular & Frontend Goodness:

I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.

  • 💼 LinkedIn — Let’s connect professionally
  • 🎥 Threads — Short-form frontend insights
  • 🐦 X (Twitter) — Developer banter + code snippets
  • 👥 BlueSky — Stay up to date on frontend trends
  • 🌟 GitHub Projects — Explore code in action
  • 🌐 Website — Everything in one place
  • 📚 Medium Blog — Long-form content and deep-dives
  • 💬 Dev Blog — Free Long-form content and deep-dives
  • ✉️ Substack — Weekly frontend stories & curated resources
  • 🧩 Portfolio — Projects, talks, and recognitions
  • ✍️ Hashnode — Developer blog posts & tech discussions

🎉 If you found this article valuable:

  • Leave a 👏 Clap
  • Drop a 💬 Comment
  • Hit 🔔 Follow for more weekly frontend insights

Let’s build cleaner, faster, and smarter web apps — together.

Stay tuned for more Angular tips, patterns, and performance tricks! 🧪🧠🚀

✨ Share Your Thoughts To 📣 Set Your Notification Preference

Top comments (0)