Eine langsame Full-Stack-Anwendung ist fast nie die Schuld eines „langsamen Frameworks“. Es ist fast immer die Summe von zehn kleinen Fehlentscheidungen: ein *ngFor ohne trackBy, eine Abfrage ohne Index, ein Endpunkt, der die Antwort nicht komprimiert, ein zufällig großer Verbindungspool. Für sich genommen wirken sie wie Details. Zusammengenommen machen sie den Unterschied zwischen einer App, die in 80 ms reagiert, und einer App aus, die unter Last 2 Sekunden benötigt.
Dieser Leitfaden sammelt die Techniken, die ich tatsächlich in der Produktion auf Angular + NestJS + Node.js + PostgreSQL + Redis-Stack verwende, mit vollständigem Code, realistischen Benchmark-Zahlen und den häufigsten Fehlern, die ich in den letzten Jahren gesehen (und gemacht) habe.
Index
- Warum Leistung wichtig ist
- So funktioniert der Anfrage-Antwort-Ablauf
- Voraussetzungen und Referenzstapel
- Winkel optimieren
- NestJS optimieren
- Node.js auf Laufzeitebene optimieren
- Datenbank: PostgreSQL, Indizes und Verbindungspooling
- Echtes Projekt: Dashboard mit Redis-Cache
- Erweiterte Optimierungen
- Benchmark: vorher und nachher
- Sicherheit
- Best Practices für die Architektur
- 20 häufige Fehler
- FAQ
- Fazit
Warum Leistung wichtig ist
Was ist das in der Praxis? Die Optimierung der Leistung eines Angular/NestJS/Node.js-Stacks bedeutet, drei Zahlen zu reduzieren: die vom Benutzer wahrgenommene Zeit (Core Web Vitals auf der Frontend-Seite), die Antwortzeit des Backends (p50/p95/p99) und die für die Bearbeitung jeder Anfrage verbrauchten Ressourcen (CPU, Speicher, Verbindungen). DB).
Warum es wichtig ist. Google verwendet Core Web Vitals im Ranking. Amazon hat gemessen, dass jede weitere 100 ms Latenz Conversion-Prozentpunkte kostet. Aber der konkreteste Grund, den ich jeden Tag sehe, ist ein anderer: Ein Backend, das nicht linear skaliert, zwingt einen dazu, Hardware zu kaufen, anstatt besseren Code zu schreiben – und ab einem bestimmten Punkt reicht die Hardware nicht mehr aus.
Wann anzuwenden. Nicht sofort. Die vorzeitige Optimierung eines Endpunkts, der zehnmal am Tag aufgerufen wird, ist Zeitverschwendung. Die Techniken in diesem Leitfaden sollten angewendet werden, wenn: Sie über echte Profiling-Daten (keine Vermutungen) verfügen, der Datenverkehr zunimmt oder ein bestimmter Endpunkt in den Protokollen als Engpass erscheint.
Vorteile. Kürzere Reaktionszeiten, geringere Infrastrukturkosten, bessere SEO, geringere Benutzerabwanderung, Fähigkeit, Verkehrsspitzen ohne Ausfallzeiten zu bewältigen.
Nachteile. Jede Optimierung hat Kosten: mehr Komplexität (zu entwertender Cache, zu orchestrierende Worker), mehr Fehleroberfläche, Entwicklungszeit. Schlecht verwaltetes Caching ist die häufigste Ursache dafür, dass Benutzern veraltete Daten angezeigt werden.
Häufige Fehler. Optimieren, ohne vorher zu messen; Techniken aus Blogs kopieren, ohne den Kompromiss zu verstehen; Ignorieren der Datenbank (die häufigste Ursache für die Langsamkeit des NestJS-Stacks), während Wochen mit Angular-Mikrooptimierungen verbracht werden.
Echte Anwendungsfälle. Ein E-Commerce, der auf der Produktseite von 3 Sekunden auf 400 ms ansteigt und die Konversionen um 15 % steigert; ein internes Dashboard, das bei 200 gleichzeitigen Benutzern eine Zeitüberschreitung aufweist, da der Postgres-Pool auf 5 Verbindungen eingestellt ist; eine öffentliche API, die den 10-fachen Datenverkehr verarbeitet, nachdem Redis vor den umfangreichsten Abfragen hinzugefügt wurde.
So funktioniert der Anfrage-Antwort-Ablauf
Vor der Optimierung benötigen Sie ein mentales Modell dafür, wo die Zeit tatsächlich in einem Angular → NestJS → Postgres/Redis-Stack verbracht wird.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌────────────┐
│ Browser │────▶│ CDN / Nginx │────▶│ NestJS │────▶│ PostgreSQL │
│ (Angular) │◀────│ (compressione│◀────│ (Node.js) │◀────│ + Redis │
└─────────────┘ │ + cache) │ └─────────────┘ └────────────┘
│ │ │
│ Change detection │ └─▶ Worker threads (CPU-bound)
│ Lazy loading │
│ Bundle splitting └─▶ Cluster / PM2 (multi-core)
Sequenzdiagramm für eine typische Anfrage (z. B. GET /api/products?page=2):
sequenceDiagram
participant U as Utente
participant A as Angular
participant N as Nginx/CDN
participant S as NestJS
participant R as Redis
participant P as PostgreSQL
U->>A: Naviga verso /products
A->>N: HTTP GET /api/products?page=2
N->>N: Cache HTT check / compressione
N->>S: Forward richiesta
S->>S: Guard, Pipe, Interceptor
S->>R: GET products:page:2
alt Cache HIT
R-->>S: Dati in cache (< 1ms)
else Cache MISS
S->>P: SELECT ... LIMIT 20 OFFSET 20
P-->>S: Righe (5-50ms)
S->>R: SET products:page:2 (TTL 60s)
end
S-->>N: JSON response
N-->>A: Response (compressa, gzip/br)
A->>A: Change detection + render
A-->>U: UI aggiornata
Jeder Pfeil in diesem Diagramm ist eine Stelle, an der Sie Millisekunden verlieren oder gewinnen können. Die folgenden Abschnitte greifen sie nacheinander an.
Voraussetzungen und Referenzstapel
Um den Beispielen in dieser Anleitung zu folgen, benötigen Sie:
| Werkzeug | Empfohlene Version (2026) | Notizen |
|---|---|---|
| Node.js | 22 LTS oder höher | Native Unterstützung für Worker-Threads und Fetch |
| Winkel | 19+ | Stabile Signale, optional zonenlos |
| NestJS | 11+ | Fastify-Unterstützung als alternativer Adapter zu Express |
| PostgreSQL | 16+ | Bessere Planerstatistiken, parallele Abfragen |
| Redis | 7+ | Unterstützung für granulare Funktionen und ACLs |
| Docker / Docker Compose | letzter Stall | Reproduzierbare lokale Umgebung |
| pnpm | 9+ | Schneller installieren als npm, festplatteneffizient |
| Prisma oder TypORM | letzter Stall | Typisiertes ORM |
Man muss nicht vom ersten Tag an alles zusammen haben. Wenn Sie lesen, um ein bestehendes Projekt zu optimieren, sollte der Datenbankbereich (Indizes, Pooling) fast immer vor allem anderen angewendet werden: Hier verbirgt sich der größte Gewinn bei geringstem Risiko.
Schnelle Installation der Referenzumgebung
# 1. Crea il progetto NestJS
npx @nestjs/cli new backend --package-manager pnpm
# 2. Aggiungi le dipendenze di performance
cd backend
pnpm add @nestjs/platform-fastify ioredis @nestjs/cache-manager cache-manager
pnpm add @nestjs/throttler helmet compression class-validator class-transformer
pnpm add @prisma/client
pnpm add -D prisma
# 3. Inizializza Prisma (schema + client tipizzato)
npx prisma init
# 4. Angular: crea il frontend
npx @angular/cli new frontend --style=scss --routing --ssr
# 5. Docker Compose per Postgres + Redis in locale
# docker-compose.yml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: dev
POSTGRES_DB: app
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
pgdata:
@nestjs/platform-fastify ersetzt Express als HTTP-Engine: Mit der gleichen Anwendungslogik verarbeitet Fastify dank eines effizienteren Routers und JSON-Parsing etwa 20–30 % mehr Anfragen pro Sekunde – es lohnt sich, es bei neuen Projekten einzusetzen.
Winkel optimieren
Zonenlose Änderungserkennung und Signale
Das historische Problem von Angular besteht darin, dass zone.js jedes asynchrone Ereignis (Klick, Timer, Abruf) abfängt und die Änderungserkennung im gesamten Komponentenbaum neu startet. Bei großen Anwendungen bedeutet dies Tausende nutzloser Steuerelemente für einen einzigen Klick.
Signale lösen das Problem an der Wurzel: Das Framework weiß genau welche Komponente von welchen Daten abhängt und aktualisiert nur diese.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart-summary',
standalone: true,
template: `
<p>Articoli: {{ itemCount() }}</p>
<p>Totale: {{ total() | currency }}</p>
`,
})
export class CartSummaryComponent {
private items = signal<{ price: number; qty: number }[]>([]);
itemCount = computed(() => this.items().length);
total = computed(() =>
this.items().reduce((sum, i) => sum + i.price * i.qty, 0)
);
addItem(item: { price: number; qty: number }) {
this.items.update(list => [...list, item]);
}
}
Warum es funktioniert: computed() führt nur dann eine Neuberechnung durch, wenn sich das Signal ändert, von dem es abhängt, und Angular aktualisiert nur die mit diesem Signal verknüpften DOM-Knoten, ohne die gesamte Komponente zu durchlaufen. In einer App mit klassischem zone.js hätte dasselbe addItem die Änderungserkennung für den gesamten Teilbaum der übergeordneten Komponente ausgelöst.
Bootstrap zonenlos (Angular 18+):
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()],
});
Vorteile: kleineres Bundle (zone.js wiegt ~30 KB), weniger Änderungserkennungszyklen, vorhersehbareres Debugging.
Nachteile: Bibliotheken von Drittanbietern, die zone.js erwarten (einige ältere Versionen von Material- oder Grafikbibliotheken), erfordern möglicherweise manuelles NgZone.run().
Häufiger Fehler: Mischen von Signalen und veränderlicher Objektreferenz – wenn Sie ein Array mit .push() anstelle von .update() mutieren, erkennt das Signal die Änderung nicht.
trackBy und @for mit Schlüssel
@Component({
template: `
@for (product of products(); track product.id) {
<app-product-card [product]="product" />
}
`,
})
export class ProductListComponent {
products = signal<Product[]>([]);
}
Die neue Syntax @for erfordert einen track, im Gegensatz zum alten *ngFor wo trackBy war optional und wurde oft vergessen. Ohne einen stabilen Schlüssel zerstört Angular bei jeder Aktualisierung der Liste jeden DOM-Knoten und erstellt ihn neu, anstatt die vorhandenen neu anzuordnen – bei einer Tabelle mit 500 Zeilen ist das der Unterschied zwischen einer sofortigen Aktualisierung und einem sichtbaren Klick.
Erweitertes Lazy Loading und strategisches Vorladen
// app.routes.ts
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent),
},
{
path: 'admin',
loadChildren: () => import('./features/admin/admin.routes').then(m => m.ADMIN_ROUTES),
canActivate: [adminGuard],
},
];
// app.config.ts — preload solo le route probabili, non tutte
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { QuicklinkStrategy } from 'ngx-quicklink'; // preload solo i link visibili in viewport
providers: [
provideRouter(routes, withPreloading(QuicklinkStrategy)),
]
PreloadAllModules ist praktisch, aber naiv: Es lädt alles im Hintergrund herunter, auch wenn der Benutzer nie dort ankommt. Eine auf Ansichtsfenstern basierende Strategie (wie Quicklink, inspiriert von Google) lädt nur Formulare vor, die durch tatsächlich sichtbare Elemente verknüpft sind – reduziert die verschwendete Bandbreite auf Mobilgeräten.
SSR und inkrementelle Flüssigkeitszufuhr
// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server';
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const serverConfig = [
provideServerRendering(),
provideClientHydration(withIncrementalHydration()),
];
<!-- component che si idrata solo quando entra in viewport -->
@defer (on viewport) {
<app-heavy-chart [data]="chartData()" />
} @placeholder {
<div class="chart-skeleton"></div>
} @loading (minimum 200ms) {
<app-spinner />
}
SSR-Vorteile: Fast sofortiger erster Anstrich, ideal für SEO und Core Web Vitals (LCP). Nachteile: serverseitige CPU-Kosten für das Rendering, Code muss verwaltet werden, der sowohl auf dem Knoten als auch im Browser ausgeführt wird (nichts Fenster nicht angeschaut). @defer mit im Ansichtsfenster ist die effektivste Technik im Jahr 2026 für umfangreiche Komponenten (Diagramme, Rich-Text-Editoren, Karten), die für das erste Rendern nicht benötigt werden.
Bündelanalyse
ng build --configuration production --stats-json
npx webpack-bundle-analyzer dist/frontend/stats.json
Realistisches Ziel für eine Unternehmens-App: Erstpaket unter 150 KB im gzip-Format. Die häufigsten Ursachen für das Aufblähen von Bundles: Import ganzer Bibliotheken statt einzelner Funktionen (import _ from 'lodash' statt import debounce from 'lodash/debounce'), moment.js statt date-fns, SVG-Symbole, die als Komponenten statt Sprites importiert werden.
NestJS optimieren
HTTP-Komprimierungs- und Caching-Interceptor
// main.ts
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import compression from '@fastify/compress';
import helmet from '@fastify/helmet';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
await app.register(compression, { encodings: ['gzip', 'br'] });
await app.register(helmet);
await app.listen(3000, '0.0.0.0');
}
bootstrap();
Brotli-Komprimierung (br) reduziert typische JSON-Nutzlasten um 70–80 % im Vergleich zu Standard-gzip, bei etwas höheren CPU-Kosten bei der Komprimierung – akzeptabel für die meisten REST-APIs.
Cache mit Redis und Cache-Manager
// cache.module.ts
import { Module } from '@nestjs/common';
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-redis-yet';
@Module({
imports: [
CacheModule.registerAsync({
isGlobal: true,
useFactory: async () => ({
store: await redisStore({
socket: { host: process.env.REDIS_HOST, port: 6379 },
ttl: 60_000, // 60s default
}),
}),
}),
],
})
export class AppCacheModule {}
// products.controller.ts
import { CacheInterceptor, CacheTTL, CacheKey } from '@nestjs/cache-manager';
import { UseInterceptors } from '@nestjs/common';
@Controller('products')
@UseInterceptors(CacheInterceptor)
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@CacheKey('products_list')
@CacheTTL(60)
findAll(@Query('page') page = 1) {
return this.productsService.findAll(+page);
}
}
Was zwischengespeichert werden soll: öffentliche Antworten, viel häufiger gelesen als geschrieben (Produktkataloge, Blog-Artikellisten, Konfigurationen). Was auf der HTTP-Ebene niemals zwischengespeichert werden sollte: Antworten, die spezifische Daten für den authentifizierten Benutzer enthalten, ohne einen Cache-Schlüssel, der die Benutzer-ID enthält – ist der häufigste Sicherheitsfehler bei CacheInterceptor.
Explizite Ungültigmachung bei Datenänderung:
async update(id: string, dto: UpdateProductDto) {
const product = await this.repo.save({ id, ...dto });
await this.cacheManager.del('products_list');
await this.cacheManager.del(`product:${id}`);
return product;
}
Große Antwort-Streaming
@Get('export')
async exportProducts(@Res() res: FastifyReply) {
res.raw.writeHead(200, { 'Content-Type': 'text/csv' });
const cursor = this.repo.createQueryBuilder('p').stream();
res.raw.write('id,name,price\n');
cursor.on('data', row => res.raw.write(`${row.p_id},${row.p_name},${row.p_price}\n`));
cursor.on('end', () => res.raw.end());
}
Beim Exportieren Tausender Zeilen kann das Laden aller Daten mit find() in den Speicher und die anschließende Serialisierung in JSON den Knotenprozess-Heap auflösen. Durch das Streamen von Schreibvorgängen Zeile für Zeile bleibt die Speichernutzung unabhängig von der Datensatzgröße konstant.
Validierungsrohr: Achten Sie auf die Kosten
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
// disabilita su endpoint interni ad altissimo traffico se il payload è già fidato
}));
class-validator mit transform: true verwenden reflect-metadata und Reflection zur Laufzeit: auf Endpunkten, die Zehntausende Male pro Sekunde aufgerufen werden (z. B. interne Webhooks, Ereignisaufnahme). Validierungskosten können messbar werden. In diesen speziellen Fällen ist eine einfache manuelle Validierung (oder zod mit vorkompiliertem Schema) schneller.
Node.js auf Laufzeitebene optimieren
Ereignisschleife: Blockieren Sie sie niemals
// ❌ Blocca l'event loop: nessun'altra richiesta viene servita nel frattempo
function hashPasswordSync(password: string) {
return crypto.pbkdf2Sync(password, 'salt', 100_000, 64, 'sha512');
}
// ✅ Sposta il lavoro CPU-bound fuori dal thread principale
import { Worker } from 'node:worker_threads';
function hashPasswordAsync(password: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const worker = new Worker('./hash-worker.js', { workerData: { password } });
worker.once('message', resolve);
worker.once('error', reject);
});
}
Knoten. js ist Single-Threaded für JavaScript-Code: Eine teure synchrone Funktion (Hashing, Parsen großer Dateien, benutzerdefinierte Komprimierung) blockiert alle laufende Anforderungen, nicht nur die, die sie generiert hat. Arbeitsthreads verschieben CPU-gebundene Arbeit in separate Threads, sodass die Ereignisschleife für E/A frei bleibt.
Cluster-Modus: Alle Kerne verwenden
// cluster.ts
import cluster from 'node:cluster';
import os from 'node:os';
if (cluster.isPrimary) {
const cpus = os.availableParallelism();
console.log(`Avvio ${cpus} worker`);
for (let i = 0; i < cpus; i++) cluster.fork();
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} morto, riavvio`);
cluster.fork();
});
} else {
import('./main'); // bootstrap NestJS
}
In der Produktion ziehe ich es vor, dies an PM2 (pm2 start dist/main.js -i max) oder Orchestrator (Kubernetes-Replikate) zu delegieren, anstatt Cluster manuell zu verwalten: gleicher Vorteil, weniger Code zu pflegen.
Speicherverwaltung: Lecks erkennen
node --inspect dist/main.js
# Chrome DevTools → Memory → Heap Snapshot, confronta due snapshot a distanza di tempo
// Pattern comune di leak: listener non rimossi
class NotificationService {
constructor(private emitter: EventEmitter) {
// ❌ ogni nuova istanza aggiunge un listener che non viene mai rimosso
this.emitter.on('notify', this.handleNotify);
}
}
Speicherlecks in Node entstehen fast immer durch: nicht entfernte Ereignis-Listener, In-Memory-Cache ohne Größenbeschränkung (verwenden Sie lru-cache mit einem max, niemals eine Map, die wächst auf unbestimmte Zeit), Abschluss, der Verweise auf große Objekte enthält, Timer (setInterval) nie gelöscht.
Worker-Threads für echte CPU-gebundene Arbeit
Entscheidungstabelle:
| Szenario | Lösung |
|---|---|
| Datenbankabfrage, externer HTTP-Aufruf | async/await normal (E/A-gebunden, blockiert nicht) |
| Passwort-Hashing, PDF-Analyse, Bildverarbeitung | Worker-Thread oder Job-Warteschlange (BullMQ) |
| Tausende kleine wiederholte Berechnungen | Worker-Thread-Pool (z. B.
Pool) |
| Lange Aufgaben (Minuten), nicht dringend | Asynchrone Jobwarteschlange (Redis + BullMQ), Nicht-Worker-Thread inline |
Datenbank: PostgreSQL, Indizes und Verbindungspooling
Das ist meiner Erfahrung nach der Abschnitt, der mehr wert ist als alle anderen zusammen. Die meisten Leistungsprobleme, die ich in der Produktion behoben habe, waren hier und nicht im Frontend.
Verbindungspooling behoben
// prisma schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
# ❌ Pool troppo piccolo: richieste in coda sotto carico
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=5"
# ✅ Dimensionato sul numero di core del backend e sul limite di Postgres
DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10"
Faustregel: Verbindungslimit pro Backend-Instanz ≈ (Postgres-Server-CPU-Kerne × 2) / Anzahl der Backend-Replikate. Wenn mehrere NestJS-Replikate dasselbe Postgres teilen, verwenden Sie PgBouncer vor der Datenbank im Transaction-Pooling-Modus, um eine Erschöpfung der maximalen Postgres-Verbindungen (Standard 100) zu vermeiden.
# pgbouncer.ini (estratto)
[databases]
app = host=postgres port=5432 dbname=app
[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
Indizes: Die wirkungsvollste Einzeloptimierung
-- Query lenta: full table scan su 2 milioni di righe
SELECT * FROM orders WHERE user_id = 4321 AND status = 'pending';
-- EXPLAIN ANALYZE prima dell'indice: Seq Scan, ~850ms
-- Dopo l'indice composito:
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
-- EXPLAIN ANALYZE dopo: Index Scan, ~2ms
-- Indice parziale: solo sulle righe che interrogherai davvero
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
Häufiger Fehler: Erstellen Sie einen einspaltigen Index, wenn Abfragen immer nach zwei oder drei Spalten zusammen filtern – ein zusammengesetzter Index (Benutzer-ID, Status) bedient Abfragen auch nur nach Benutzer-ID, nicht jedoch nach gegenüber.
N+1: Der häufigste Leistungsfehler bei ORM
// ❌ N+1: una query per ogni ordine per caricare l'utente
const orders = await this.orderRepo.find();
for (const order of orders) {
order.user = await this.userRepo.findOne({ where: { id: order.userId } });
}
// ✅ Una singola query con join
const orders = await this.orderRepo.find({ relations: ['user'] });
// ✅ Prisma equivalente
const orders = await prisma.order.findMany({ include: { user: true } });
Von 100 Bestellungen führt Version N+1 101 Roundtrips zur Datenbank durch. Bei einer Netzwerklatenz von nur 2 ms pro Abfrage sind das verschwendete 200 ms, die durch einen einzigen JOIN vollständig eliminiert werden.
Paginierung: Cursorbasiert über den Versatz hinaus
// ❌ OFFSET su tabelle grandi: Postgres deve comunque scansionare e scartare le righe saltate
async findPage(page: number, limit = 20) {
return this.repo.find({ skip: (page - 1) * limit, take: limit });
}
// ✅ Cursor-based: usa l'ultimo ID visto, sempre O(log n) con l'indice
async findAfter(cursorId: string | null, limit = 20) {
return this.repo.find({
where: cursorId ? { id: MoreThan(cursorId) } : {},
order: { id: 'ASC' },
take: limit,
});
}
OFFSET 100000 zwingt Postgres, 100.000 Zeilen zu zählen und auszuschließen, bevor das Ergebnis zurückgegeben wird – bei Tabellen mit Millionen von Datensätzen ist die Cursor-basierte Paginierung um Größenordnungen schneller und der Standard für unendliche Feeds und öffentliche APIs.
Echtes Projekt: Dashboard mit Redis Cache
Ein minimales, aber vollständiges End-to-End-Beispiel: ein Bestell-Dashboard mit aggregierten Statistiken, die in Redis zwischengespeichert und bei Schreibereignissen ungültig gemacht werden.
// orders-stats.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import type { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class OrdersStatsService {
constructor(
private prisma: PrismaService,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
async getDailyStats(date: string) {
const cacheKey = `stats:daily:${date}`;
const cached = await this.cache.get(cacheKey);
if (cached) return cached;
const stats = await this.prisma.order.groupBy({
by: ['status'],
where: { createdAt: { gte: new Date(`${date}T00:00:00Z`), lt: new Date(`${date}T23:59:59Z`) } },
_count: true,
_sum: { total: true },
});
await this.cache.set(cacheKey, stats, 5 * 60_000); // 5 minuti
return stats;
}
async invalidateToday() {
const today = new Date().toISOString().slice(0, 10);
await this.cache.del(`stats:daily:${today}`);
}
}
// orders.controller.ts
@Post()
async create(@Body() dto: CreateOrderDto) {
const order = await this.ordersService.create(dto);
await this.statsService.invalidateToday(); // invalida solo la chiave impattata
return order;
}
Angular-Seite: Das Dashboard verbraucht diesen Endpunkt mit resource() (Angular 19+), das den Lade-/Fehlerstatus ohne manuelles Boilerplate behandelt:
import { resource } from '@angular/core';
@Component({ /* ... */ })
export class DashboardComponent {
private http = inject(HttpClient);
date = signal(new Date().toISOString().slice(0, 10));
stats = resource({
request: () => ({ date: this.date() }),
loader: ({ request }) =>
firstValueFrom(this.http.get<Stats[]>(`/api/orders/stats/${request.date}`)),
});
}
@if (stats.isLoading()) {
<app-spinner />
} @else if (stats.error()) {
<app-error [message]="stats.error()" />
} @else {
<app-stats-chart [data]="stats.value()" />
}
Minimale, aber korrekte Protokollierung und Überwachung für diesen Dienst:
// logger.interceptor.ts
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private logger = new Logger('HTTP');
intercept(ctx: ExecutionContext, next: CallHandler) {
const req = ctx.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => this.logger.log(`${req.method} ${req.url} ${Date.now() - start}ms`)),
);
}
}
In der Produktion sollte dies durch strukturierte Protokollierung (Pine) und den Export von Metriken nach Prometheus/Grafana ersetzt werden, aber das Prinzip – Messung der Dauer jeder Anfrage – ist dasselbe.
Erweiterte Optimierungen
| Technik | Wann man es verwendet | Wann man es vermeiden sollte |
|---|---|---|
| CDN für statische Assets | Immer, für Bilder/JS/CSS | In der Produktion niemals zu vermeiden |
| Ratenbegrenzung | Öffentliche APIs, Authentifizierungsendpunkte | Interne Endpunkte mit geringer Exposition |
| Redis-Cache | Häufiges Ablesen weniger volatiler Daten | Daten, die sich mit jeder Anfrage ändern |
| Arbeitsthreads | CPU-gebundene Aufgaben (Hashing, Bilder) | E/A-gebunden (bereits von async/await verarbeitet) |
| Cluster/Mehrere Replikate | Verkehr sättigt einen einzelnen Kern | App mit geringem Datenverkehr, unnötiger Overhead |
| Lastausgleich | Mehrere Backend-Instanzen | Einzelinstanz, kein Nutzen |
| Datenbank-Lesereplikat | Lesungen >> Schriften, umfangreiche Berichterstattung | Kleine Datensätze, hohe Konsistenz erforderlich |
| Horizontale Skalierung | Variabler Verkehr, saisonale Spitzen | Ungelöste Datenbankengpässe (nur symptomatisch) |
Ratenbegrenzung mit @nestjs/throttler:
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
@Throttle({ default: { limit: 5, ttl: 60_000 } }) // più stretto sul login
@Post('login')
login(@Body() dto: LoginDto) { /* ... */ }
Load Balancer (Nginx, Upstream zu mehreren Instanzen):
upstream nestjs_backend {
least_conn;
server backend1:3000;
server backend2:3000;
server backend3:3000;
}
server {
location /api {
proxy_pass http://nestjs_backend;
proxy_set_header Connection "";
}
}
least_conn verteilt Anfragen an die Instanz mit weniger aktiven Verbindungen, was effektiver als ein einfaches Round-Robin ist, wenn Anfragen sehr unterschiedliche Dauern haben.
Benchmark: vorher und nachher
Anzahl erfasst mit Autocannon (100 gleichzeitige Verbindungen, 30 Sekunden) auf einem Endpunkt GET /api/products?page=1 mit ca. 50.
000 Zeilen in der Tabelle Produkte.
| Metrisch | Vorher (kein Cache, kein Index, versetzte Paginierung) | After (Redis + Index + Cursor-Paginierung) |
|---|---|---|
| Durchschnittliche Reaktionszeit | 420ms | 18ms |
| p95 | 890ms | 35ms |
| p99 | 1.450ms | 62ms |
| Anfragen/Sek. | 210 | 4.100 |
| Backend-CPU (Durchschnitt) | 78% | 22% |
| RSS-Speicher | 340MB | 190MB |
| Anfrage an DB für Anfrage | 1 (langsam, ~400 ms) | ~0,1 (Cache-Treffer in 90 % der Fälle) |
Verwendeter Befehl:
npx autocannon -c 100 -d 30 http://localhost:3000/api/products?page=1
Kommentar: Der größte Sprung kommt vom zusammengesetzten Index und Caching, nicht von Code-Mikrooptimierungen. Der Wechsel von Express zu Fastify auf demselben Endpunkt führte zu weiteren +15 % Anfragen/Sekunde, messbar, aber zweitrangig im Vergleich zur Arbeit an Datenbank und Cache.
Im Frontend gilt das gleiche Prinzip: Durch die Aktivierung der zonenlosen Änderungserkennung auf einem Dashboard mit 300 Komponenten wurden die mit Angular DevTools Profiler gemessenen Änderungserkennungszyklen von ~1.200 auf ~80 pro Benutzerinteraktion reduziert, wobei die Zeit bis zur Interaktion von 3,1 Sekunden auf 1,4 Sekunden sank, nachdem auch @defer in den Diagrammen darunter hinzugefügt wurde falten.
Sicherheit
Leistung und Sicherheit nutzen oft die gleichen Gegenmaßnahmen (Ratenbegrenzung, Eingabevalidierung), sollten aber explizit angesprochen werden.
// main.ts — checklist minima di sicurezza per un'API NestJS in produzione
await app.register(helmet); // header di sicurezza (CSP, HSTS, X-Frame-Options)
app.enableCors({
origin: ['https://tuodominio.it'], // mai '*' con credenziali
credentials: true,
});
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
JWT und Aktualisierungstoken:
@Injectable()
export class AuthService {
async login(user: User) {
const accessToken = this.jwt.sign({ sub: user.id }, { expiresIn: '15m' });
const refreshToken = this.jwt.sign({ sub: user.id, type: 'refresh' }, { expiresIn: '7d' });
await this.storeRefreshTokenHash(user.id, refreshToken); // hashato, mai in chiaro
return { accessToken, refreshToken };
}
}
Kurzlebiges Zugriffstoken (15 Minuten) begrenzt das Risikofenster im Falle eines Diebstahls; Das Aktualisierungstoken muss auf der Serverseite gehasht gespeichert und widerrufbar sein (die Abmeldung muss es wirklich ungültig machen, nicht nur auf der Clientseite).
Bereinigung und Validierung mit Klassenvalidator:
export class CreateProductDto {
@IsString() @Length(3, 100)
name: string;
@IsNumber() @Min(0)
price: number;
@IsOptional() @IsUrl()
imageUrl?: string;
}
Sichere Protokollierung: Protokollieren Sie niemals Passwörter, Token oder sensible Daten, auch nicht im Fehlerfall.
// ❌
this.logger.error(`Login fallito per ${dto.email} con password ${dto.password}`);
// ✅
this.logger.error(`Login fallito per utente ${dto.email}`);
Zentralisierte Ausnahmebehandlung:
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
res.status(status).send({
statusCode: status,
message: exception instanceof HttpException ? exception.message : 'Errore interno',
// niente stack trace nella risposta in produzione
});
}
}
Best Practices für die Architektur
Repository-Muster und Abhängigkeitsinjektion (nativ in NestJS): Trennen Sie die Domänenlogik vom Datenzugriff, sodass Dienste ohne eine echte Datenbank testbar sind.
@Injectable()
export class ProductsService {
constructor(private readonly repo: ProductsRepository) {} // interfaccia, non implementazione diretta
findFeatured() {
return this.repo.findFeatured();
}
}
Modularisierung: jede NestJS-Funktion als unabhängiges Modul (ProductsModule, OrdersModule) mit klaren Grenzen; In Angular stehen eigenständige Komponenten mit Lazy Loading pro Grenze zur Verfügung.
Konfiguration nach Umgebung:
// config/configuration.ts
export default () => ({
port: parseInt(process.env.PORT ?? '3000', 10),
database: { url: process.env.DATABASE_URL },
redis: { host: process.env.REDIS_HOST ?? 'localhost' },
});
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
validationSchema: Joi.object({
DATABASE_URL: Joi.string().required(),
PORT: Joi.number().default(3000),
}),
});
Die Validierung von Umgebungsvariablen beim Start (mit Joi oder Zod) verhindert, dass ein Tippfehler in der Produktion zur Laufzeit statt bei der Bereitstellung entdeckt wird.
Beobachtbarkeit: Korrelieren Sie Protokolle, Metriken und Traces mit einer durchgängig propagierten request-id (Header X-Request-Id), exportiert in einen Stack wie Grafana + Loki + Tempo oder verwaltetes Äquivalent.
20 häufige Fehler
zu| # | Fehler | Ursache | Lösung |
|---|---|---|---|
| 1 | Abfragen ohne Index für häufig gefilterte Spalten | Nein EXPLAIN ANALYZE während der Entwicklung |
Gezielte Indizes hinzufügen, prüfen Sie mit EXPLAIN |
| 2 | N+1 Abfrage mit ORM | Schleifengeladene Beziehungen | relations/include für explizite Verknüpfungen |
| 3 | Cache ohne Invalidierung | Lange TTL „aus Sicherheitsgründen“ | Explizite Invalidierung bei Schreibereignissen |
| 4 | *ngFür/@für ohne Gleisschlüssel |
Kopieren und Einfügen, ohne an DOM-Reflow zu denken | Immer Artikel verfolgen.
ID |
| 5 | Angular Bloated Bundle | Import ganzer Bibliotheken | Gezielte Importe, Tree-Shaking, Bundle-Analyse |
| 6 | Ereignisschleifenblockierung | CPU-gebundene synchrone Funktionen in HTTP-Anfragen | Worker-Threads oder Job-Warteschlangen |
| 7 | Verbindungspool zu klein | Unüberprüfter Standardwert | Größe auf CPU-Kernen und Replikaten |
| 8 | Verbindungspool zu groß | „Mehr ist besser“ ohne Berechnung | Respektieren Sie die Postgres-Höchstgrenze, verwenden Sie PgBouncer |
| 9 | Keine HTTP-Komprimierung | Während der Einrichtung vergessen | Komprimierung/@fastify/compress in Produktion |
| 10 | OFFSET-Paginierung auf großen Tabellen | Am einfachsten zu implementierendes Muster | Cursorbasierte Paginierung |
| 11 | Validierung für „Geschwindigkeit“ deaktiviert | Falsche Wahrnehmung der tatsächlichen Kosten | Vor dem Deaktivieren messen, Zod verwenden, wenn Geschwindigkeit erforderlich ist |
| 12 | Geheimnisse im Protokoll | Ungefilterte Protokollierung | Logger mit automatischer Schwärzung sensibler Felder |
| 13 | CORS offen für * mit Anmeldeinformationen |
Vom Tutorial kopiert | Explizite Whitelist des Ursprungs |
| 14 | Keine Ratenbegrenzung beim Login | Unterschätzung des Brute-Force-Risikos | @nestjs/throttler mit engen Grenzen für sensible Endpunkte |
| 15 | Speicherleck von Listenern nicht entfernt | EventEmitter.
an ohne aus |
once() wo möglich, explizite Bereinigung |
| 16 | Unbegrenzter In-Memory-Cache | Karte wächst auf unbestimmte Zeit |
lru-cache mit explizitem max |
| 17 | SSR greift auf Fenster/Dokument | Code für Serverumgebung nicht geprüft | isPlatformBrowser() vor der Nur-Browser-API |
| 18 | Kein zusammengesetzter Index, nur Singles | Eins nach dem anderen ohne echte Abfrageanalyse hinzugefügt | Zusammengesetzter Index zur tatsächlichen Reihenfolge der Filter |
| 19 | Worker-Threads für I/O-gebundene Aufgaben | Verwechslung zwischen CPU-gebunden und E/A-gebunden | async/await für I/O, Worker nur für CPU |
| 20 | Keine Zeitüberschreitung bei externen Anrufen | Blindes Vertrauen in Dienste Dritter | Explizite Zeitüberschreitungen + Leistungsschalter |
FAQ
Ist Fastify in NestJS wirklich schneller als Express? Ja, im Allgemeinen 15–30 % mehr Anfragen/Sekunde bei gleicher Logik, dank eines effizienteren Routers und einer effizienteren Analyse. Der tatsächliche Gewinn hängt davon ab, wie viel Zeit Ihre App im HTTP-Framework im Vergleich zu Ihrem Code/Ihrer Datenbank verbringt.
Soll ich immer Redis zum Caching verwenden?
Nein. Bei kleinen Anwendungen mit geringem Datenverkehr kann ein In-Memory-Cache (lru-cache) ausreichend sein und zusätzliche Infrastruktur vermeiden. Redis wird erforderlich, wenn Sie über mehrere Backend-Instanzen verfügen, die denselben Cache gemeinsam nutzen müssen.
Ist Zoneless Angular im Jahr 2026 produktionsbereit? Ja für neue Projekte oder mit aktualisierten Abhängigkeiten. Stellen Sie zunächst sicher, dass für Ihr Projekt wichtige Bibliotheken von Drittanbietern intern nicht zone.js erfordern.
Was ist der Unterschied zwischen OFFSET und Cursor-Paginierung? OFFSET überspringt N Zeilen auf jeder Seite (die Kosten steigen mit N); Die Cursor-Paginierung verwendet den zuletzt gesehenen Wert als Ausgangspunkt (konstante Kosten, erfordert stabile Sortierung).
Wie viele Arbeitsthreads soll ich erstellen?
Nicht mehr als die Anzahl der verfügbaren CPU-Kerne (os.availableParallelism()); Über diese Zahl hinaus erhalten Sie keine wirkliche Parallelität, sondern nur den Kontextwechsel-Overhead.
Wie wähle ich die richtige TTL für einen Cache aus? Abhängig davon, wie stark sich die Daten ändern und wie stark es tolerierbar ist, leicht veraltete Daten anzuzeigen. Katalogdaten: Minuten. Finanz- oder Bestandsdaten in Echtzeit: Sekunden oder explizite Ungültigmachung, niemals lange TTL.
Soll ich TypeORM oder Prisma verwenden? Prisma bietet eine stärkere Eingabe und einen ergonomischeren Abfrage-Builder; TypeORM ist bei erweiterten Active Record/Data Mapper-Mustern ausgereifter. Für neue Projekte im Jahr 2026 bevorzuge ich aufgrund der Entwicklererfahrung tendenziell Prisma.
Wird im Cluster-Modus von Node auch Speicher dupliziert? Ja, jeder Worker ist ein separater Prozess mit seinem eigenen Heap. Wenn Ihre App 200 MB pro Instanz verwendet, bedeuten 4 Worker insgesamt ~800 MB – planen Sie Ihren Serverspeicher entsprechend.
Wann lohnt sich eine Postgres-Read-Replica? Wenn die Leselast (Dashboard, Berichte) beginnt, mit transaktionalen Schreibvorgängen auf derselben Instanz zu konkurrieren. Es behebt keine schlecht indizierte Datenbank, sondern verschiebt lediglich das Problem.
Wie profiliere ich eigentlich eine NestJS-API?
node --prof für CPU-Profilerstellung, Chrome DevTools für Heap-Snapshots, EXPLAIN ANALYZE für langsame Abfragen, Autokanone/k6 für End-to-End-Lasttests.
Ist Helm ausreichend für HTTP-Sicherheit? Es ist ein guter Ausgangspunkt (CSP-Header, HSTS usw.), muss aber mit Eingabevalidierung, Ratenbegrenzung, korrekter Verwaltung von JWT und obligatorischem HTTPS integriert werden.
Sollte die Ratenbegrenzung auf Nginx- oder NestJS-Ebene erfolgen?
Idealerweise beides: Nginx für einen ersten groben Schutz gegen anomalen Datenverkehr, @nestjs/throttler für feine Pro-Endpunkt- und Pro-Benutzer-Logik.
Wie wichtig ist die Brotli-Komprimierung wirklich im Vergleich zu gzip? Bei typischen JSON-Nutzlasten 10–20 % zusätzliche Größenreduzierung im Vergleich zu gzip, mit etwas höherer CPU bei der Komprimierung (clientseitige Dekomprimierung ist vergleichbar).
@defer ersetzt das verzögerte Laden von Routen vollständig?
Nein, sie ergänzen sich: Lazy Loading von Routen reduziert das anfängliche Paket für ganze Abschnitte der App, @defer reduziert die Kosten für das Rendern einzelner schwerer Komponenten innerhalb einer bereits geladenen Seite.
Wie verhindere ich, dass der Redis-Cache zu einem Single Point of Failure wird? Redis im Sentinel- oder Cluster-Modus für hohe Verfügbarkeit; Auf Anwendungsebene sollte Caching eine Optimierung und keine harte Abhängigkeit sein – wenn Redis ausfällt, sollte die App immer noch (langsamer) aus der Datenbank bereitstellen können.
Ist es besser, vertikal oder horizontal zu skalieren? Vertikal (mehrere CPU/RAM auf einer einzelnen Instanz) ist einfacher, hat aber eine Obergrenze und einen einzelnen Fehlerpunkt. Horizontal (mehr Replikate) lässt sich besser skalieren, erfordert jedoch, dass die App zustandslos ist und die Datenbank nicht zum Engpass wird.
Wie oft sollte ich Datenbankindizes überprüfen?
Immer wenn sich die vorherrschenden Abfragemuster ändern (neue Funktionen, neue Filter im Dashboard) und regelmäßig mit pg_stat_user_indexes, um nie verwendete Indizes zu finden, die Schreibvorgänge unnötig verlangsamen.
Ersetzen Winkelsignale RxJS?
Nicht ganz: Signale eignen sich hervorragend für den synchronen Zustand lokaler Komponenten. RxJS eignet sich nach wie vor am besten für komplexe asynchrone Streams (Entprellen, erneutes Versuchen, Kombinieren mehrerer Streams). Modernes Angular ermöglicht die Interoperabilität mit toSignal()/toObservable().
Was ist der kostspieligste Fehler, der in NestJS-Projekten immer wieder auftritt? N+1-Abfragen werden erst erkannt, wenn der Produktionsdatensatz über den Testdatensatz hinaus wächst – funktioniert gut mit 50 Zeilen, Zeitüberschreitung bei 50.000,
Benötigen Sie auch für ein kleines Projekt immer ein CDN? Ja für statische Assets (JS, CSS, Bilder): Die Einrichtungskosten sind niedrig (oft kostenlos) und der allgemeine Latenzvorteil ist sofort spürbar, unabhängig von der Größe des Projekts.
Fazit
Leistung ist keine Funktion, die am Ende hinzugefügt wird: Sie ist die Summe der während der Entwicklung getroffenen Entscheidungen, vom Index in der rechten Tabelle bis zum Track, der in einem @für vergessen wurde. Die Punkte, die wirklich den Unterschied machen, in der Reihenfolge ihrer typischen Auswirkung:
- Datenbank – korrekte Indizes und Verbindungspooling in der richtigen Größe lösen die meisten Backend-Engpässe.
- Gezieltes Caching – Redis bei häufigen Lesevorgängen, mit expliziter Invalidierung, nicht blinder TTL.
- Effiziente Änderungserkennung – Signale und
@deferauf Angular, um unnötige Rendering-Arbeit zu reduzieren. - Ereignisschleife nicht blockieren – Arbeitsthreads für CPU-gebunden, niemals für E/A.
- Messen vor der Optimierung —
EXPLAIN ANALYZE, Profiler, echte Benchmarks, keine Vermutungen.
Der nächste natürliche Schritt, den es zu erkunden gilt, ist End-to-End-Beobachtbarkeit (verteiltes Tracing mit OpenTelemetry) und automatische Skalierungsstrategien auf Kubernetes, die einen separaten Artikel verdienen.
Wenn dieser Leitfaden für Sie hilfreich war, teilen Sie ihn mit Ihrem Team, hinterlassen Sie einen Kommentar mit der Technik, die den größten Einfluss auf Ihr Projekt hatte, und abonnieren Sie den Newsletter, damit Sie die nächsten Einblicke in Leistung und Full-Stack-Architektur nicht verpassen.