Das DataLoader-Muster: N+1-Abfragen ohne ORM eliminieren
N+1 ist kein ORM-Problem: implementiere das DataLoader-Batching-Muster in TypeScript und fasse redundante Abfragen jeder Datenquelle zusammen.

Das N+1-Abfrageproblem wird den ORMs angelastet. Lazy Loading reparieren, eager: true hinzufügen, einen JOIN verwenden. Aber N+1 ist kein ORM-Problem — es ist ein strukturelles Problem, das immer dann auftritt, wenn Daten innerhalb einer Schleife abgerufen werden. GraphQL-Resolver, REST-Endpunkte, die mehrere Services zusammensetzen, React Server Components, die datenbankseitige Aufrufe pro Zeile machen: das Muster ist überall, und der ORM ist nur der Bote.
Die Lösung ist nicht immer ein JOIN. Manchmal leben Daten in verschiedenen Services. Manchmal rufst du eine API eines Drittanbieters auf. Das DataLoader-Muster — ursprünglich aus Facebooks GraphQL-Infrastruktur — löst das auf der richtigen Abstraktionsebene, ohne deine Resolver-Grenzen einzuebnen.
Wie N+1 in der Praxis aussieht
Hier ist ein realistisches Beispiel: ein GraphQL-Resolver, der eine Liste von Blogposts zurückgibt, jeder mit einem zugehörigen Autor.
// ❌ N+1 — authorResolver fires once per post, 20 posts = 21 queries
const postsResolver = async () => {
return db.query<Post[]>("SELECT * FROM posts ORDER BY created_at DESC LIMIT 20");
};
const authorResolver = async (post: Post): Promise<User> => {
return db.queryOne<User>("SELECT * FROM users WHERE id = $1", [post.authorId]);
};Jeder Resolver für sich wirkt völlig vernünftig. Das Problem zeigt sich erst zur Laufzeit unter Last. Zwanzig Posts ergeben zwanzig Autor-Abfragen zusätzlich zur anfänglichen Listenabfrage.
Der naive Fix funktioniert, wenn die Daten in derselben Datenbank liegen:
// ✅ For same-DB data: batch in the parent, build a lookup map
const postsWithAuthorsResolver = async () => {
const posts = await db.query<Post[]>(
"SELECT * FROM posts ORDER BY created_at DESC LIMIT 20"
);
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await db.query<User[]>(
"SELECT * FROM users WHERE id = ANY($1)",
[authorIds]
);
const byId = new Map(authors.map((a) => [a.id, a]));
return posts.map((p) => ({ ...p, author: byId.get(p.authorId) }));
};Aber das bricht die Resolver-Trennung, die GraphQL komponierbar macht. Und es fällt komplett auseinander, wenn die Autordaten aus einem separaten User-Service kommen.
Die Batching-Primitive
Der Kern der Sache ist, einzelne Abrufe bis zum Ende des aktuellen Event-Loop-Ticks zu verzögern und sie dann alle als einen einzigen Batch zu flushen. Genau dafür wurde queueMicrotask entwickelt.
type BatchFn<K, V> = (keys: readonly K[]) => Promise<Map<K, V>>;
class DataLoader<K, V> {
private readonly batchFn: BatchFn<K, V>;
private queue: Array<{
key: K;
resolve: (value: V) => void;
reject: (error: Error) => void;
}> = [];
private scheduled = false;
private cache = new Map<K, Promise<V>>();
constructor(batchFn: BatchFn<K, V>) {
this.batchFn = batchFn;
}
load(key: K): Promise<V> {
// Return cached promise — same key in one request never fires twice
const cached = this.cache.get(key);
if (cached) return cached;
const promise = new Promise<V>((resolve, reject) => {
this.queue.push({ key, resolve, reject });
});
this.cache.set(key, promise);
if (!this.scheduled) {
this.scheduled = true;
queueMicrotask(() => this.flush());
}
return promise;
}
private async flush(): Promise<void> {
const batch = this.queue.splice(0);
this.scheduled = false;
const keys = batch.map((item) => item.key);
try {
const results = await this.batchFn(keys);
for (const { key, resolve, reject } of batch) {
const value = results.get(key);
if (value !== undefined) {
resolve(value);
} else {
reject(new Error(`DataLoader: no result for key "${String(key)}"`));
}
}
} catch (err) {
for (const { reject } of batch) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}
}
}load(key) wird N Mal über verschiedene Resolver aufgerufen. Alle Aufrufe innerhalb desselben Microtask-Checkpoints werden in die Warteschlange gestellt. flush() wird genau einmal ausgeführt, sendet eine einzige Batch-Anfrage und verteilt die Ergebnisse dann an die einzelnen wartenden Promises zurück.
Anbindung an eine echte Datenquelle
In der Batch-Funktion steckt die Strategie. Hier ist eine, die auf PostgreSQL basiert:
const batchUsers: BatchFn<string, User> = async (userIds) => {
const rows = await db.query<User[]>(
"SELECT * FROM users WHERE id = ANY($1)",
[userIds]
);
return new Map(rows.map((u) => [u.id, u]));
};
const userLoader = new DataLoader(batchUsers);
// Each resolver calls load() — batching is transparent
const authorResolver = async (post: Post): Promise<User> => {
return userLoader.load(post.authorId);
};Bei einem Microservice-Aufruf sieht die Batch-Funktion identisch aus — nur die SQL-Abfrage wird gegen einen HTTP-Request getauscht:
const batchUsersFromService: BatchFn<string, User> = async (userIds) => {
const response = await fetch(`${USER_SERVICE_URL}/users/batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...userIds] }),
});
if (!response.ok) {
throw new Error(`User service responded with ${response.status}`);
}
const users: User[] = await response.json();
return new Map(users.map((u) => [u.id, u]));
};Der Resolver-Code ändert sich nicht. Die Batching-Strategie ist vollständig in der Batch-Funktion des Loaders gekapselt.
Scoping pro Request
Es gibt eine subtile Gefahr in der obigen Implementierung: die cache-Map lebt auf der Loader-Instanz. Erstellst du einen Loader im Modul-Scope, lecken zwischengespeicherte Daten aus einem Request in den nächsten.
Teile niemals eine DataLoader-Instanz über Requests hinweg. Der pro-Request-Cache ist ein Feature — aber nur, wenn der Loader für jeden Request-Lebenszyklus neu erstellt wird.
Das Standardmuster ist AsyncLocalStorage, um Loader auf einen Request zu scopen:
import { AsyncLocalStorage } from "node:async_hooks";
interface RequestLoaders {
user: DataLoader<string, User>;
post: DataLoader<string, Post>;
}
const loaderStorage = new AsyncLocalStorage<RequestLoaders>();
// Express / Fastify middleware — fresh loaders per request
export function attachLoaders(
req: Request,
res: Response,
next: NextFunction
): void {
loaderStorage.run(
{
user: new DataLoader(batchUsers),
post: new DataLoader(batchPosts),
},
next
);
}
// Anywhere downstream in the call stack
export function getLoaders(): RequestLoaders {
const store = loaderStorage.getStore();
if (!store) throw new Error("getLoaders() called outside request context");
return store;
}Frische Loader pro Request, keine Kreuzkontamination, automatische Cache-Deduplizierung innerhalb einer einzelnen Request-Lebenszeit.
Wann Batching nicht das richtige Werkzeug ist
DataLoader reduziert N+1 auf 1, aber manchmal ist selbst eine einzelne Batch-Abfrage mehr, als du brauchst — oder weniger. Es zählt, zu wissen, wann du danach greifst.
| Muster | Geeignet für |
|---|---|
| DataLoader (pro-Request-Cache) | Auflösen von Beziehungen in GraphQL/REST-Antworten |
| Redis mit TTL | Daten über Requests hinweg, die selten ändern |
SQL-JOIN | Ko-lokalisierte Daten mit vorhersehbaren, gleichförmigen Zugriffsmustern |
| Materialisierte View | Leseintensive Aggregationen, die nach Plan aktualisiert werden |
| In-Process-LRU-Cache | Heiße Referenzdaten (Feature-Flags, Config, Enums) |
Greife nicht zu DataLoader, wenn ein JOIN einfacher ist — es verdient seine Komplexität, wenn Daten Service-Grenzen überspannen oder wenn Resolver-Isolation wichtiger ist als rohe Abfrageeffizienz. Das Muster setzt auch voraus, dass deine Batch-Funktion beliebige Key-Sets akzeptieren kann; wenn die Downstream-API nur das Abrufen einzelner Datensätze unterstützt, löst du das falsche Problem.
Wichtige Erkenntnisse
- N+1 ist strukturell, nicht ORM-spezifisch — es tritt immer dann auf, wenn Daten innerhalb einer Schleife abgerufen werden, unabhängig von der Datenschicht
- Zuerst verzögern, dann flushen —
queueMicrotasksammelt alleload()-Aufrufe innerhalb eines Ticks in einem einzigen Batch; keine manuelle Koordination nötig - Die Batch-Funktion ist der Adapter — dieselbe
DataLoader-Implementierung funktioniert mit SQL, HTTP, Redis oder jeder asynchronen Quelle - Scoppe Loader auf den Request — ein Loader auf Modulebene liefert nachfolgenden Requests veraltete Cache-Daten; verwende
AsyncLocalStorage, um frische Instanzen zu erzeugen - Cache-Deduplizierung ist kostenlos — zweimaliger Aufruf von
loader.load(id)mit demselben Key gibt dieselbe Promise zurück und verhindert redundante laufende Anfragen


