Firebase has become the default choice for solo Angular teams or small startups wanting to avoid maintaining a backend API. Yet moving from demo to production code quickly reveals common pitfalls: fragmented authentication state management, overly permissive Firestore rules, or hosting configured without a proper cache strategy. This article details the architecture we use in production, from reactive patterns to deployment optimization.
Firebase Authentication: Beyond the Basic Login
The classic mistake is treating Firebase Auth as a simple wrapper around signInWithEmailAndPassword() . In reality, you're managing complex global state: the user can be unauthenticated, loading, authenticated with an expired token, or have a persisted session. Deploying this flow without reactive architecture quickly breeds race conditions and memory leaks.
The right approach centralizes this state via a service observing authStateChanged() at app bootstrap. We use a hybrid pattern: an Angular signal for the current user (fast, synchronous for templates) and an Observable for state changes (integration with guards and interceptors). This dual approach lets templates use authService.userSignal() for rendering without subscribe calls (signals are auto-tracked), guards react to state changes via effect() , and interceptors inject tokens without complexity.
// auth.service.ts
export class AuthService {
private auth = inject(Auth);
private db = inject(Firestore);
userSignal = signal<User | null>(null);
isLoading = signal(true);
constructor() {
onAuthStateChanged(this.auth, async (user) => {
if (user) {
// Load user data from Firestore
const docSnap = await getDoc(doc(this.db, 'users', user.uid));
this.userSignal.set({ ...user, profile: docSnap.data() });
} else {
this.userSignal.set(null);
}
this.isLoading.set(false);
});
}
signUp(email: string, password: string) {
return createUserWithEmailAndPassword(this.auth, email, password)
.then(cred => this.createUserProfile(cred.user));
}
private createUserProfile(user: User) {
return setDoc(doc(this.db, 'users', user.uid), {
email: user.email,
createdAt: serverTimestamp(),
role: 'user'
});
}
} The critical point is initializing authStateChanged() at the root level and never letting this stream terminate prematurely. This ensures your auth state is always synchronized with Firebase's backend, and any navigation or permission checks remain consistent. Pair this with a route guard that checks both isLoading and userSignal to prevent premature redirects.
Firestore: Data Architecture and Security Rules
Firestore shines through simplicity, but that's also its trap: it's easy to start without structure, then end up with overly permissive rules or N+1 query patterns. The first step is modeling collections correctly by thinking about read patterns. Avoid monolithic documents; prefer intelligent denormalization paired with subcollections for 1-N relationships. This approach keeps queries fast and rule logic straightforward.
Security rules must be written before client code, not after. Here's a secure template for a typical app: each user manages their personal data, can read public profiles of others, and an admin has full access. The common pitfall is testing in development mode (everything allowed) and forgetting to enable rules before production. We've seen apps expose sensitive data simply because rules remained in allow read, write: if false . This is catastrophic and easily avoided by treating security rules as first-class code.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Users
match /users/{uid} {
allow read: if request.auth.uid == uid || get(/databases/$(database)/documents/users/$(uid)).data.isPublic == true;
allow write: if request.auth.uid == uid;
// Subcollection: private posts
match /posts/{postId} {
allow read, write: if request.auth.uid == uid;
}
}
// Public documents (readable by all)
match /public/{docId} {
allow read: if true;
allow write: if request.auth.uid == resource.data.authorId && resource.data.authorId == request.auth.uid;
}
// Admin only
match /admin/{docId} {
allow read, write: if request.auth.uid != null && exists(/databases/$(database)/documents/users/$(request.auth.uid)) && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
}
}
} On the Angular side, exploit reactivity: create services that encapsulate Firestore logic and return Observables or Signals. Use collectionData() with where() , limit() , and orderBy() for efficient pagination. Composite indexes become necessary once you combine multiple conditions; Firebase suggests creating them via the console when a query requires them. Finally, implement a client-side caching strategy (via NgRx or local services) to avoid re-fetching the same data repeatedly.
Firebase Hosting: Configuration, Caching, and Deployment
Firebase Hosting excels for Angular SPAs but requires precise configuration. By default, static files are cached aggressively (30 days), which is dangerous if you don't version your assets. Best practice: leave default cache for hashed files (main.abc123.js, styles.def456.css), but set Cache-Control: no-cache for index.html and config files. This ensures users always get the latest app shell while assets remain cached indefinitely.
Here's the recommended Firebase configuration:
{
"hosting": {
"public": "dist/my-app",
"ignore": ["firebase.json", ".firebaserc"],
"headers": [
{
"source": "index.html",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
},
{
"source": "**/*.@(js|css|woff2)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
} The rewrites section is critical: it redirects all routes to index.html so Angular Router handles navigation. Without this, direct access to /dashboard returns 404. Also configure CORS headers if you're serving an API (though Firebase Cloud Functions is more idiomatic). For deployment, integrate Firebase CLI into your CI/CD: firebase deploy --only hosting after building Angular in production mode with ng build --configuration production . Version your .firebaserc so all developers target the correct Firebase project.
Common Pitfalls and Antipatterns
Pitfall number one: failing to handle authentication or Firestore errors gracefully. Users see a broken app instead of a clear message. Always wrap Firebase calls in try-catch or RxJS operators like catchError() . Second pitfall: forgetting that onAuthStateChanged() fires on reload, emphasizing the importance of the isLoading signal to prevent premature redirects. Third: not testing Firestore rules locally via the emulator before production. Firebase offers an excellent emulation suite; using it saves costly bugs.
Another common antipattern is manually storing tokens. Firebase handles this automatically via session cookies (in web mode), so don't try extracting and persisting the JWT yourself unless you have a strong reason (e.g., Ionic/Capacitor app with native storage). Finally, don't ship unoptimized bundles: Firebase Admin SDK must never reach the client. Use only the compatibility SDK (compat) or the modular SDK for web, and tree-shake aggressively.
Conclusion: Solid Architecture and Maintainability
Production-ready Firebase + Angular integration rests on three pillars: centralized, reactive auth state management; well-modeled and tested Firestore rules; and a clearly defined caching and deployment strategy. Investing a few days in this foundation prevents weeks of debugging and refactoring later. Use signals for performance, Observables for complex async flows, and remember Firebase is a managed service: test locally with the emulator, never trust default rules, and version your configuration. With this approach, you iterate rapidly while maintaining confidence in security and scalability.