Onlineshop-Performance optimieren: Was wirklich was bringt (ohne Voodoo)

Letzte Woche bei einem Kunden: Kampagne live, Traffic hoch, alle happy – bis der Checkout plötzlich bei jedem zweiten Klick „gefühlt“ hängen blieb. Nicht komplett down, aber so langsam, dass du beim Warten schon anfängst, deine Lebensentscheidungen zu hinterfragen. War am Ende kein „mysteriöser Cloud-Bug“, sondern ein ziemlich banaler Mix aus Cache-Miss, dicker SQL-Query und zu viel Arbeit im Request-Thread. Passiert halt.
Interessiert an diesem Thema?
Kontaktieren Sie uns für eine kostenlose Beratung →Problem Statement
Erfahrungsgemäß reduziert Lazy Loading die initiale Ladezeit am stärksten
Beispiel: B2B Shopware 6 Shop (≈ 50k SKUs)
API Response Time von 600ms auf 180ms optimiert
Performance Optimization Checklist
- ✓ Cache-Strategie implementiert
- ✓ Lazy Loading für Bilder aktiviert
- ✓ Code Splitting durchgeführt
- ✓ Database Queries optimiert
- ✓ CDN konfiguriert
Onlineshop-Performance ist selten ein Problem. Es sind meistens fünf kleine Sachen, die zusammen richtig wehtun: zu viele DB-Roundtrips, zu große Payloads, unklare Cache-Strategie, Bilder wie aus 2012 und dann noch irgendein Third-Party-Script, das sich wie Kaugummi zieht. Und ja: Du merkst es immer zuerst im Checkout oder in der Produktsuche. Immer.
Ein Fehler, den ich oft sehe: Wir optimieren „die Seite“ (Frontend) und ignorieren, dass der Server pro Request 20 Dinge synchron macht. Oder andersrum: Wir tunen die DB und liefern weiterhin 4 MB JSON an den Client. Beides bringt’s nicht so richtig.
Technical Background
Ganz grob: Die Zeit, die dein Shop braucht, besteht aus Netzwerk + Server + Datenbank + Rendering + Third Parties. Und weil’s ein Onlineshop ist, kommen noch Klassiker dazu: Variationen, Preise/Bestände pro Kunde, Personalisierung, Gutscheine, Versandlogik. Das macht Caching „irgendwie“ schwieriger, aber nicht unmöglich.
Ich denke gern in drei Ebenen:
- Edge/CDN: alles, was du global schnell ausliefern kannst (Assets, Bilder, teilweise HTML, manchmal auch API GETs).
- Application Cache: Redis/memory, fragmentbasiert, mit sauberer Invalidierung (ja, das Wort ist unsexy, aber wichtig).
- DB/Query: Indizes, N+1 vermeiden, Aggregationen vorziehen, und: weniger Daten holen.
Und dann kommt noch Observability rein: Wenn du nicht messen kannst, optimierst du nach Bauchgefühl. Ich bin nicht 100% sicher, aber ich hab noch nie ein Projekt gesehen, wo „Bauchgefühl“ besser war als ein Tracing-Span mit einer klaren Zahl dran.
Implementation Steps
Ich würd’s so angehen, ohne dogmatisch zu sein:
1) Erst messen, dann schrauben
Nimm dir einen kritischen Request (z.B. /checkout oder /search) und bau Tracing/Timing ein. Nicht überall. Nur da, wo’s weh tut. Wenn du schon OpenTelemetry hast: perfekt. Wenn nicht: auch okay, zur Not erstmal mit simplem Logging und Korrelation.
// Express/Node Beispiel: Timing + Request-Id, pragmatisch gehalten
import crypto from "crypto";
import type { Request, Response, NextFunction } from "express";
export function perfMiddleware(req: Request, res: Response, next: NextFunction) {
const rid = (req.headers["x-request-id"] as string) || crypto.randomUUID();
const start = process.hrtime.bigint();
res.setHeader("x-request-id", rid);
res.on("finish", () => {
const end = process.hrtime.bigint();
const ms = Number(end - start) / 1_000_000;
// TODO: später optimieren (z.B. Sampling, Aggregation)
console.log(JSON.stringify({
rid,
path: req.path,
method: req.method,
status: res.statusCode,
ms: Math.round(ms)
}));
});
next();
}
Wenn du danach siehst „ah, Server braucht 1200ms“, dann gehst du tiefer. Wenn Server 80ms hat, aber TTFB beim Nutzer hoch ist, ist’s eher CDN/Netzwerk/Third Party.
2) DB: N+1 und fehlende Indizes killen dich still
Wenn Search oder Category-Listing langsam ist, ist’s fast immer: falscher Index oder zu viel Join/Filter ohne passenden Index. Und ganz ehrlich: EXPLAIN ist dein bester Freund.
-- Beispiel: Produktsuche / Listing, typische Filter
EXPLAIN (ANALYZE, BUFFERS)
SELECT p.id, p.name, p.price
FROM products p
JOIN product_visibility v ON v.product_id = p.id
WHERE v.channel_id = $1
AND p.is_active = true
AND p.category_id = $2
ORDER BY p.rank DESC
LIMIT 48 OFFSET $3;
-- Typische Indizes, die ich hier erwarte (je nach DB)
-- products(category_id, is_active, rank)
-- product_visibility(channel_id, product_id)
Ich hab schon Shops gesehen, da war der Query-Plan ein Seq Scan über 3 Mio Produkte, weil der Index zwar existierte, aber die Spaltenreihenfolge nicht gepasst hat. Fühlt sich erstmal wie Magie an. Ist aber nur Mathe.
3) Caching: Nicht alles, aber das Richtige
Ich mag Cache-aside für viele Shop-Read-Endpoints: erst Cache probieren, sonst DB/API, dann Cache befüllen. Aber: TTL allein reicht selten. Du brauchst zumindest eine grobe Invalidierungsstrategie (z.B. bei Preisupdate, Bestand, Produktstatus).
# Python Beispiel: Cache-aside mit Redis für ein Listing
# (Framework egal, die Idee zählt)
import json
import time
from redis import Redis
redis = Redis(host="localhost", port=6379, decode_responses=True)
def get_category_listing(category_id: str, channel_id: str) -> dict:
cache_key = f"cat:{channel_id}:{category_id}:listing:v1"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
# Simuliert: Daten aus DB / Search-Service
listing = {
"categoryId": category_id,
"items": fetch_items_from_db(category_id, channel_id),
"generatedAt": int(time.time())
}
# TTL bewusst kurz, weil Bestand/Preis variabel sein kann
# TODO: später optimieren: gezielte Invalidierung bei Produktupdates
redis.setex(cache_key, 60, json.dumps(listing))
return listing
def fetch_items_from_db(category_id: str, channel_id: str):
# ...
return []
Übrigens: Wenn du personalisierte Preise hast, cache nicht „die komplette Response“, sondern cache Bausteine: Produkt-IDs, Basisdaten, vielleicht Preislisten-IDs – und „finalisier“ im Backend. Sonst explodiert dir die Cache-Cardinality.
4) Payload reduzieren: weniger JSON, weniger Drama
Ein Klassiker: Listing liefert pro Produkt 40 Felder, obwohl die Kachel nur 6 braucht. Klingt klein. Ist’s aber nicht, wenn du 48 Produkte pro Seite hast und das auf Mobile über schlechtes Netz geht.
// Beispiel: gezielte Feldselektion (DTO), damit nicht aus Versehen alles rausfliegt
type ProductTeaserDTO = {
id: string;
name: string;
price: number;
imageUrl: string;
rank: number;
};
function toTeaser(p: any): ProductTeaserDTO {
return {
id: p.id,
name: p.name,
price: p.price,
imageUrl: p.primaryImage?.url,
rank: p.rank
};
}
Wenn du GraphQL nutzt: super, dann zwingt’s dich eh zur Selektion. Wenn nicht: baue DTOs. Ja, ist extra Arbeit. Aber die beste Art Extra-Arbeit.
5) Bilder/Assets: das „schnellste“ ist das, was du nicht lädst
WebP/AVIF, richtige Größen (srcset), Lazy Loading, CDN mit aggressivem Cache für Assets. Und bitte: kein 2400px Hero-Bild als PNG, weil „war halt im CMS“. Hab ich alles schon gesehen.
Best Practices
- Hot Paths schützen: Checkout, Cart, Login – alles, was Umsatz direkt betrifft, bekommt Priorität beim Tuning.
- Timeouts & Circuit Breaker: Third Parties (Payment, Recommendations) müssen Zeitbudgets haben. Sonst hängt dein Request an deren Laune.
- Asynchronisieren, wo es geht: Tracking, E-Mail, CRM-Events raus aus dem Request. In Queue damit.
- DB-Verbindungen begrenzen: Connection Pools sauber einstellen. Zu viele parallele Queries können eine DB auch einfach „zumachen“.
- Cache-Invaliation bewusst entscheiden: TTL-only ist okay als Start. Aber sobald Marketing „Preisupdate jetzt sofort!“ will, brauchst du Events/Tags.
Kurzer Reality-Check: Perfekt wird’s nie. Und manchmal ist „schnell genug“ wirklich genug. Aber du willst halt vermeiden, dass Performance nur dann Thema ist, wenn die Bude brennt.
Real-World Example
Bei einem Projekt letzten Monat hatten wir eine Kategorie-Seite, die unter Last plötzlich von ~250ms auf 2-3 Sekunden gesprungen ist. Nicht immer. Nur wenn viele gleichzeitig kamen. Im Tracing sahen wir: DB-CPU hoch, und pro Request sind wir in eine zusätzliche Query gelaufen, weil ein Feature-Flag-Check pro Produkt (!) nochmal eine Tabelle abgefragt hat. N+1, nur halt gut versteckt.
Fix war dann ziemlich unspektakulär: Feature-Flags einmal pro Request laden (oder cachen), dazu Index auf der Flag-Tabelle und im Listing nur die Felder liefern, die das Frontend wirklich braucht. Danach war’s wieder stabil, und wir konnten das CDN-Caching für das HTML-Fragment etwas aggressiver machen. Die größte „Magie“ war eigentlich nur: hinschauen, messen, aufräumen.
Ach ja: Wir haben auch ein Third-Party-Script rausgeworfen, das im Checkout synchron geladen wurde. Das war so ein Moment, wo du dich fragst, warum das jemals okay war. 🙃


