GraphQL optimieren
Die Kategorieseite ist der wichtigste Performance-Hotspot in fast jedem Magento-Shop. Zu viele Felder, zu tiefe Resolver-Ketten und fehlende Caching-Strategien machen sie langsamer als nötig. Der Hebel liegt in der Query – nicht im Server.
Inhaltsverzeichnis
- 1. Warum Kategorieseiten der wichtigste GraphQL-Hotspot sind
- 2. Anatomie einer Kategorieseiten-Query
- 3. Felder reduzieren: Nur anfordern, was gerendert wird
- 4. Aggregationen und Filter effizient modellieren
- 5. N+1-Probleme in Resolver-Ketten erkennen und beheben
- 6. Paginierung: Cursor vs. Offset in Magento
- 7. Caching-Strategie für Kategorieseiten-Queries
- 8. Typische Fehler bei Kategorieseiten-Queries
- 9. Falsch vs. Richtig: Query-Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Kategorieseiten der wichtigste GraphQL-Hotspot sind
Die Kategorieseite ist in fast jedem Magento-Shop der meistbesuchte Seiten-Typ und gleichzeitig der teuerste GraphQL-Request. Eine typische Kategorieseite lädt eine Produktliste mit Bildern, Preisen und Varianten, dazu Filter-Aggregationen, Gesamtanzahl für Paginierung und Kategoriemetadaten. All das kommt in einer einzigen GraphQL-Query – und wenn diese Query nicht sorgfältig designed ist, treibt sie Resolver-Kosten in die Höhe, die weder durch Hardware noch durch Caching vollständig kompensierbar sind.
Der entscheidende Unterschied zu REST: Bei REST ist die Antwortstruktur serverseitig definiert. Bei GraphQL entscheidet der Client, welche Felder er anfordert – und damit auch, wie teuer der Request wird. Frontends, die bei der Entwicklung einfach "alles verfügbare" anfordern, erzeugen in Magento Resolver-Ketten, die EAV-Attribute, Bilder, Preise und Lagerstatus für jedes Produkt einzeln abfragen. Das Ergebnis sind hunderte Datenbankzugriffe für eine einzige Seite.
2. Anatomie einer Kategorieseiten-Query
Eine vollständige Kategorieseiten-Query in Magento hat typischerweise drei Hauptabschnitte: die Produktliste mit den Feldern, die für die Kacheln auf der Seite gerendert werden, die Aggregationen für die Filter-Seitenleiste und page_info für die Paginierung. Dazu kommen oft Kategoriemetadaten, die für SEO, Breadcrumbs und Seiten-Header gebraucht werden.
Jeder dieser Abschnitte hat ein anderes Caching-Profil und eine andere Resolver-Tiefe. Die Produktliste ist am teuersten, weil jedes Produkt individuelle EAV-Attribute, Preisregeln und Bestandsinformationen hat. Aggregationen sind günstiger, weil sie auf Datenbankebene aggregiert werden. Paginierungsinformationen sind billig. Kategoriemetadaten sind meist cachebar. Wer diese vier Abschnitte kennt und ihren relativen Kostenbeitrag versteht, kann die Query gezielt optimieren.
# Optimized category page query — only request fields that are actually rendered
# Split into logical sections with documented cost profile
query CategoryPage(
$categoryId: String!
$pageSize: Int!
$currentPage: Int!
$sort: ProductAttributeSortInput
$filter: ProductAttributeFilterInput
) {
# Section 1: category metadata (cheapest — cacheable)
categoryList(filters: { ids: { eq: $categoryId } }) {
uid
name
description
meta_title
meta_description
image
breadcrumbs {
category_id
category_name
category_url_key
}
}
# Section 2: product listing (most expensive — EAV, prices, images)
products(
filter: $filter
pageSize: $pageSize
currentPage: $currentPage
sort: $sort
) {
# Section 3: pagination info (cheap)
total_count
page_info {
current_page
page_size
total_pages
}
# Section 4: product tiles — minimal fields only
items {
uid
sku
name
url_key
url_suffix
# Image: request only the required size
small_image {
url
label
}
# Price: minimum_price only — avoid full price_range for listings
price_range {
minimum_price {
regular_price { value currency }
final_price { value currency }
discount { amount_off percent_off }
}
}
# Rating for listing display
rating_summary
review_count
}
}
}
3. Felder reduzieren: Nur anfordern, was gerendert wird
Das wirksamste Optimierungsmittel bei Kategorieseiten-Queries ist das konsequente Reduzieren auf die Felder, die tatsächlich auf der Seite gerendert werden. Jedes zusätzliche Feld in einer Produktlisten-Query kann einen oder mehrere Datenbankzugriffe pro Produkt auslösen. Bei 24 Produkten pro Seite bedeutet jedes überflüssige Feld potenziell 24 unnötige Queries.
Die häufigsten Kandidaten für Felder, die angefordert aber nicht gerendert werden: description (langer HTML-Text, der auf der Detailseite angezeigt wird, nicht auf der Listenseite), short_description (oft nicht in Kacheln dargestellt), media_gallery (alle Bilder statt nur small_image), related_products (Resolver-Explosion: für jedes Produkt werden verwandte Produkte geladen) und price_range mit maximum_price (teurer als minimum_price allein). Ein systematischer Abgleich zwischen den angeforderten Feldern und dem tatsächlichen Rendering-Template ist die effizienteste Art, versteckte Performance-Verluste aufzudecken.
4. Aggregationen und Filter effizient modellieren
Aggregationen sind die Filter-Optionen in der Seitenleiste: Farbe, Größe, Preis, Marke, Bewertung. In Magento kommen sie über das aggregations-Feld der Produkt-Query und werden von OpenSearch berechnet. Das bedeutet, dass Aggregationen eine separate Anfrage an den Search-Backend darstellen – teuer, aber cacheable. Der wichtige Optimierungspunkt ist die Auswahl der aggregierten Attribute: Wenn alle 50 EAV-Attribute als aggregierbar markiert sind, wird die Aggregations-Berechnung entsprechend aufwändiger.
Praktische Empfehlung: Nur Attribute als filterable markieren, die tatsächlich in der Seitenleiste angezeigt werden. Das reduziert die Aggregations-Kosten und verkleinert den Response-Payload. Im GraphQL-Query kann man außerdem die Anzahl der Aggregations-Optionen über aggregations { options(limit: 10) } begrenzen – für Attribute mit vielen möglichen Werten (z.B. Hersteller mit hunderten Optionen) ist eine sinnvolle Beschränkung eine bessere UX und schneller zu rendern.
# Aggregations query — efficient filter sidebar loading
# Request only needed aggregation attributes, limit options per filter
query CategoryFilters(
$filter: ProductAttributeFilterInput!
$pageSize: Int!
) {
products(filter: $filter, pageSize: $pageSize) {
total_count
# Aggregations for filter sidebar — only requested attributes returned
aggregations(filter: { position: { from: "0" to: "100" } }) {
attribute_code
label
count
# Limit options to prevent huge payloads for attributes with many values
options {
label
value
count
}
}
items {
uid
sku
name
url_key
small_image { url label }
price_range {
minimum_price {
final_price { value currency }
}
}
}
page_info {
current_page
page_size
total_pages
}
}
}
5. N+1-Probleme in Resolver-Ketten erkennen und beheben
Das N+1-Problem ist das häufigste Performance-Antipattern in GraphQL-APIs und tritt in Magento-Kategorieseiten besonders deutlich auf. Das Grundmuster: Ein Resolver für eine Liste lädt N Produkte, dann ruft für jedes Produkt ein weiterer Resolver ein zugehöriges Objekt ab – N weitere Queries. Bei 24 Produkten werden so aus einer Query 25 Datenbankzugriffe. Bei tiefer Verschachtelung (Produkte → Kategorien → übergeordnete Kategorien) wachsen die Zugriffe exponentiell.
In Magento ist das typische N+1-Szenario die Kombination aus Produktliste und individuellen Attributen oder Kategoriezuordnungen. Magento selbst verwendet intern Batching-Mechanismen für viele Felder, aber eigene Erweiterungen, die Custom Resolver implementieren, sind häufig anfällig. Die Lösung ist entweder DataLoader-ähnliches Batching (alle IDs sammeln, in einer Query laden) oder das Verlegen der Datenzugriffe auf eine Ebene höher, sodass alle benötigten Daten in der übergeordneten Resolver-Methode vorgeladen werden. Das Werkzeug zur Diagnose: bin/log graphql.log und Query-Profiling mit aktiviertem Debugging.
6. Paginierung: Cursor vs. Offset in Magento
Magento GraphQL nutzt Offset-basierte Paginierung über currentPage und pageSize. Das ist die einfachere der beiden Paginierungsarten und für E-Commerce-Kategorieseiten mit typischen Seitengrößen von 12 bis 48 Produkten durchaus geeignet. Cursor-basierte Paginierung wäre für sehr große Datensätze performanter (weil kein OFFSET in SQL), ist aber in Magento nativ nicht verfügbar und muss für Custom-Implementierungen explizit gebaut werden.
Ein wichtiger Performance-Aspekt bei Offset-Paginierung: Je tiefer die Seite, desto teurer die Datenbankabfrage. Seite 100 mit einer Seitengröße von 24 bedeutet, dass MySQL oder OpenSearch die ersten 2376 Ergebnisse verwerfen muss, bevor die Seite 100 ausgeliefert werden kann. Für Kategorieseiten, die normalerweise nicht tief paginiert werden, ist das kein praktisches Problem. Für Importer oder Crawler, die systematisch alle Seiten durchgehen, kann das zum Engpass werden. In solchen Fällen empfiehlt sich ein separater API-Endpunkt mit Cursor-Paginierung oder einem ID-basierten Iterationsansatz.
7. Caching-Strategie für Kategorieseiten-Queries
Kategorieseiten sind einer der cachbarsten Inhalte im Shop: Produktlisten ändern sich nicht bei jedem Request, Preise bleiben für Standardkunden gleich und Aggregationen sind stabil bis zum nächsten Katalog-Update. Die Caching-Strategie für Kategorieseiten-Queries hat drei Ebenen: HTTP-Caching für anonyme Requests, serverseitiges Query-Result-Caching für komplexe Resolver-Ergebnisse und Edge-Caching über CDN für geografisch verteilte Traffic-Spitzen.
In Magento ist das integrierte Varnish-Caching für GraphQL-Endpunkte standardmäßig aktiv. Das Problem: Magento invalidiert den Cache aggressiv bei Katalog-Änderungen – eine Preisänderung für ein einziges Produkt kann den Cache für hunderte Kategorieseiten leeren. Für Headless-Setups mit Hyvä oder einem separaten Frontend-Server empfiehlt sich ein granularerer Invalidierungsansatz über Cache-Tags: Jede Query-Antwort wird mit den relevanten Produkt- und Kategorie-IDs getaggt, und Invalidierung erfolgt selektiv nur für betroffene Seiten.
8. Typische Fehler bei Kategorieseiten-Queries
Der häufigste Fehler bei Kategorieseiten-Queries ist das Anfordern von related_products oder upsell_products im Kontext der Produktliste. Diese Felder triggern für jedes Produkt in der Liste einen weiteren Resolver-Aufruf, der seinerseits wieder eine vollständige Produktliste lädt. Das erzeugt eine exponentiell wachsende Anzahl von Datenbankzugriffen, die selbst bei kleinen Produktlisten zu Timeouts führen kann. Verwandte Produkte gehören auf die Detailseite, nicht in die Kategorielistenquery.
Ein zweiter typischer Fehler: Die Query-Variablen werden nicht über GraphQL-Variablen übergeben, sondern direkt in die Query eingebettet. Das verhindert Persisted Queries und macht HTTP-Caching deutlich ineffizienter, weil jede Query-Variation als separater Cache-Key behandelt wird. Alle variablen Teile einer Kategorieseiten-Query – Filter, Seitenzahl, Seitengröße, Sortierung – gehören als Variablen-Argumente übergeben, damit Tooling und Caching optimal funktionieren.
| Problem | Symptom | Lösung | Aufwand |
|---|---|---|---|
| Zu viele Felder | Hohe Query-Zeit, großer Payload | Felder auf tatsächlich gerenderte beschränken | Gering |
| N+1 in Resolver | Viele SQL-Queries, langsame Seiten | Batching im Resolver einführen | Mittel |
| related_products in Liste | Exponentieller Query-Anstieg | Feld aus Kategorieliste entfernen | Gering |
| Kein Query-Caching | Jede Seite frisch aus DB geladen | Varnish / CDN-Caching konfigurieren | Mittel |
| Hardcodierte Werte in Query | Cache-Fragmentierung, kein Persisted-Query | Alle variablen Werte als Variablen übergeben | Gering |
9. Falsch vs. Richtig: Query-Vergleich
Der direkteste Weg, den Nutzen von Kategorieseiten-Optimierungen zu demonstrieren, ist der Vergleich zweier Queries für denselben Anwendungsfall: eine unoptimierte Query, die alles verfügbare anfordert, und eine optimierte Query, die nur das rendert, was die Seite braucht. Der Unterschied in der Resolver-Tiefe und der Anzahl der Datenbankzugriffe ist oft erheblich – zweistellige Faktoren bei der Query-Zeit sind in der Praxis keine Seltenheit.
Die optimierte Query ist nicht weniger "korrekt" als die unoptimierte – sie ist bewusst eingeschränkt. Diese Bewusstheit ist der Unterschied zwischen einem GraphQL-Endpunkt, der zufällig performant ist, und einem, der strukturiert für Performance designed wurde. Die bewusste Einschränkung gehört dokumentiert: Warum wird description nicht angefordert? Weil sie auf der Kategorienseite nicht gerendert wird und auf der Detailseite separat geladen werden kann. Diese Dokumentation gehört als Kommentar in die Query-Fixture-Datei, nicht in ein Wiki-Dokument, das niemand liest.
# WRONG: Over-fetching on category listing page
# Causes: deep resolver chains, N+1 for related products, large payload
query CategoryPageBad($categoryId: String!) {
products(filter: { category_id: { eq: $categoryId } }, pageSize: 24) {
items {
sku
name
url_key
description { html } # never shown in listing — expensive
short_description { html } # usually not shown in listing either
media_gallery { # all images — only thumbnail needed
url
label
position
}
price_range {
minimum_price { regular_price { value } final_price { value } discount { amount_off percent_off } }
maximum_price { regular_price { value } final_price { value } } # not needed
}
related_products { # N+1 explosion: 24 * N extra queries
sku
name
price_range { minimum_price { final_price { value } } }
}
upsell_products { sku name } # same issue as related_products
crosssell_products { sku }
}
}
}
# RIGHT: Lean category listing — only fields actually rendered
# Each field justified by a component that renders it
query CategoryPageGood(
$filter: ProductAttributeFilterInput!
$pageSize: Int!
$currentPage: Int!
$sort: ProductAttributeSortInput
) {
products(
filter: $filter
pageSize: $pageSize
currentPage: $currentPage
sort: $sort
) {
total_count
page_info { current_page page_size total_pages }
items {
uid
sku # needed for cart operations
name # rendered in product tile
url_key # needed for product link
small_image { url label } # single thumbnail image
price_range {
minimum_price {
regular_price { value currency }
final_price { value currency }
discount { percent_off } # for sale badge
}
}
rating_summary # star rating in tile
review_count # review count badge
}
}
}
Kategorieseiten über Magento GraphQL optimieren — Das Wichtigste auf einen Blick
Felder reduzieren
Nur Felder anfordern, die auf der Kategorieseite tatsächlich gerendert werden. description, media_gallery und related_products gehören nicht in die Listenseiten-Query.
N+1 beheben
Resolver-Ketten mit Batching absichern. related_products in der Produktliste ist das häufigste N+1-Muster – immer entfernen aus Kategorieseiten-Queries.
Variablen nutzen
Filter, Sortierung und Paginierung immer als GraphQL-Variablen – nicht hardcodiert. Ermöglicht Persisted Queries und effizientes HTTP-Caching.
Caching aktivieren
Varnish-Caching für anonyme Requests, granulare Cache-Tag-Invalidierung für Headless-Setups. Kategorieseiten sind hoch cacheable.
10. Zusammenfassung
Kategorieseiten über Magento GraphQL zu optimieren bedeutet vor allem, bewusste Entscheidungen darüber zu treffen, welche Felder wirklich gebraucht werden und welche Resolver-Tiefe vertretbar ist. Der größte Hebel liegt in der Query selbst – nicht in der Infrastruktur. Eine schlanke, gut gebaute Kategorieseiten-Query mit korrekten Variablen, ohne N+1-Felder und mit passendem Caching-Header liefert bessere Ergebnisse als ein überdimensionierter Server hinter einer schlecht optimierten Query.
Die praktischen Schritte in der richtigen Reihenfolge: Zuerst messen – welche Felder werden tatsächlich gerendert, wie lange dauert die Query, wie viele SQL-Queries werden erzeugt? Dann reduzieren – alle Felder entfernen, die das Rendering nicht braucht. Dann N+1-Muster identifizieren und Batching einführen. Schließlich Caching konfigurieren und validieren, dass Invalidierung korrekt funktioniert. Diese Reihenfolge stellt sicher, dass Optimierungsaufwand dort investiert wird, wo der tatsächliche Engpass liegt.