Das Ergebnis vorab: Ein Foto-Album mit 1.204 Bildern brauchte mehrere Sekunden, bis überhaupt etwas erschien. Die Ursache war ein N+1-Problem bei signierten URLs: 2.408 einzelne HTTP-Requests pro Seitenaufruf. Nach der Umstellung auf Batch-Signierung sind es ~12 Requests — die API antwortet in 0,98 s (kalt) bzw. 0,64 s (warm). Alle Zahlen in diesem Artikel sind echte Produktionsmessungen vom 10.–12. Juni 2026.

Illustration: Raster aus hunderten Foto-Thumbnails, die mit hoher Geschwindigkeit laden

Das Setup: eine DSGVO-Fotoplattform auf 2 CPU-Kernen

dsgvoschulfotos.de ist eine DSGVO-konforme Foto-Plattform für Schulen und Kitas, an der wir als Entwicklungsprojekt arbeiten: Fotografen laden Event-Alben hoch, ordnen Fotos Kindern zu, Eltern greifen ohne Account per QR-Code und PIN zu. Der Stack: Next.js 16 (App Router), React 19, selbst gehostetes Supabase (16–20 Docker-Container inkl. Kong API-Gateway, Storage-API und imgproxy), PostgreSQL mit Row Level Security, Fotos auf Hetzner S3.

Alles läuft auf einem einzigen Hetzner-Server mit 2 vCPU und 8 GB RAM. Das ist bewusst so — ein soziales Projekt rechnet sich nicht mit einem Kubernetes-Cluster. Aber es bedeutet: Jede ineffiziente Architekturentscheidung wird sichtbar, sobald echte Daten kommen.

Und sie kamen: Das erste echte Fotografen-Album hatte 1.246 Fotos und 944 MB. Unser Testalbum mit 1.204 Fotos zeigte dieselben Symptome. Bei 50 Fotos war alles unsichtbar gewesen.

Das Symptom: leerer Bildschirm trotz Lazy Loading

Die Galerie nutzte bereits loading="lazy" für alle Bilder und vorgenerierte WebP-Thumbnails. Trotzdem: Beim Öffnen des 1.204-Foto-Albums blieb der Bildschirm mehrere Sekunden leer. Die Bilder waren also nicht das Problem — die Zeit verschwand, bevor der Browser überhaupt eine einzige Bild-URL kannte.

Die Ursache: ein N+1-Problem, aber für URL-Signierung

Die Fotos liegen in einem privaten Bucket. Jede Bild-URL ist eine signierte URL — ein zeitlich begrenzter, kryptografisch signierter Link, den die Supabase Storage-API ausstellt. Der Album-Endpoint tat dafür pro Foto Folgendes:

// Vorher: pro Foto zwei HTTP-Round-Trips zur Storage-API
const [url, fullUrl] = await Promise.all([
    getPhotoPreviewUrl(photo, 'cover', 86400),  // Grid-Größe
    getPhotoPreviewUrl(photo, 'full', 86400),   // Lightbox-Größe
])

Jeder createSignedUrl()-Aufruf ist ein eigener HTTP-Request vom Next.js-Container durch das Kong-Gateway zum Storage-Container. Für 1.204 Fotos sind das 2.408 HTTP-Requests in einem einzigen Promise.all — gegen ein Gateway und einen Storage-Service, die sich 2 CPU-Kerne mit allem anderen teilen. Der Browser bekommt nichts, bis der letzte Request fertig ist.

Das ist das klassische N+1-Problem — nur nicht in der Datenbank, sondern auf HTTP-Ebene. Dasselbe Muster steckte an drei Stellen: in der Album-Ansicht des Fotografen, in der elternseitigen QR-Galerie und in der Superadmin-Ansicht. Letztere war sogar noch schlimmer: Sie forderte Echtzeit-Transformationen der Originale über imgproxy an und umging die vorgenerierten Varianten komplett.

Der Fix: Batch-Signierung mit createSignedUrls()

Die Varianten-Pfade sind deterministisch (variants/photographer/{photoId}_thumb.webp), also lassen sie sich gesammelt signieren — die Storage-API hat dafür einen Batch-Endpoint:

// Nachher: ein HTTP-Request pro 200 Fotos, Chunks parallel
const { data } = await supabase.storage
    .from('albums')
    .createSignedUrls(paths, expiresIn)  // paths.length ≤ 200

Für das 1.204-Foto-Album: 2.408 URLs in 7 Chunks à maximal 200 Pfade pro Bildgröße = ~12 HTTP-Requests statt 2.408 — eine 200-fache Reduktion der Round-Trips. Fotos ohne fertige Varianten fallen auf den alten Einzel-Pfad zurück, mit begrenzter Parallelität (maximal 10 gleichzeitig), damit ein halb optimiertes Album kontrolliert langsamer wird statt den Server zu überrennen.

Diagramm: Vorher 2.408 einzelne HTTP-Requests durch Kong zur Storage-API, nachher ~12 Batch-Requests mit createSignedUrls
Vorher/Nachher: 2.408 Einzel-Requests gegen ~12 Batch-Requests für dasselbe Album.

Die Messwerte: vorher gegen nachher

MetrikVorherNachher
Storage-API-Aufrufe pro Album-Load2.408~12
API-Antwort, 1.204 Fotos (kalt)mehrere Sekunden0,98 s
API-Antwort, 1.204 Fotos (warm)0,64 s
Kindbezogene Galerie (26 Fotos)0,31 s / 21.661 Bytes
Einzelnes Thumbnail10-s-TimeoutsHTTP 200, 47.178 Bytes WebP

Die Nachher-Zeiten enthalten den kompletten TLS-Handshake und Transfer von einem externen Client — nicht nur die Serverzeit.

Bonus-Fund 1: 800-px-Bilder in 250-px-Zellen

Während der Analyse fiel auf: Das Grid lud die 800-px-cover-Variante in Zellen, die mit 200–400 px gerendert werden. Der Wechsel auf die 300-px-thumb-Variante (gemessen: 47 KB pro Bild) senkt die Bildbandbreite beim Scrollen um den Faktor 5–10 — beim kompletten Durchscrollen des Albums der Unterschied zwischen ~150 MB und ~25 MB. Die 1.920-px-full-Variante bleibt der Lightbox vorbehalten.

Bonus-Fund 2: 1.204 gemountete DOM-Knoten

loading="lazy" spart Bytes, keine Layout-Arbeit: React mountet trotzdem jede Grid-Zelle, und der Browser rechnet Style und Layout für alle 1.204 Zellen. Die Lösung war eine CSS-Zeile pro Zelle:

content-visibility: auto;
contain-intrinsic-size: auto 250px;

Der Browser überspringt damit die Render-Arbeit für Zellen außerhalb des Viewports und hält die Scrollbar mit dem 250-px-Platzhalter stabil. Das sind geschätzt ~80 % des Nutzens einer virtualisierten Liste (z. B. react-virtuoso) für ~1 % der Implementierungskosten — kein Komponenten-Umbau, keine zusätzliche Bibliothek.

Was wir bewusst nicht gefixt haben

Signierte URLs enthalten ein Ablauf-Token, das bei jedem Seitenaufruf neu generiert wird. Identisches Bild, anderer URL-String — der Browser-Cache ist damit strukturell nutzlos, jeder Reload lädt jedes Thumbnail neu herunter. Der geplante Fix: ein serverseitiger URL-Cache (In-Memory-Map mit Schlüssel photoId:variant, TTL kürzer als die 24-h-Gültigkeit), damit wiederholte Aufrufe byte-identische URLs bekommen und der HTTP-Cache greift.

Das ist der versteckte Preis von Signed-URL-Architekturen, über den kaum jemand spricht: CDN- und Browser-Caching sterben leise, wenn die URL-Generierung nicht pro TTL-Fenster deterministisch gemacht wird.

Wie die Zahlen verifiziert wurden

  • Direkt in PostgreSQL bestätigt, dass alle 1.204/1.204 Fotos den Status optimization_status = 'completed' hatten.
  • Den PIN-geschützten Eltern-Flow durchgemessen: PIN → Session-Token → Foto-Endpoint, 26 Fotos in 0,31 s, jede URL eine _thumb.webp-Variante.
  • Für den Volltest ein temporäres Album-Token eingefügt, die Galerie-API kalt und warm gecurlt (0,98 s / 0,64 s, 1.001.757 Bytes, 1.204/1.204 URLs vorhanden) — und das Token danach gelöscht.
  • Unit-Tests pinnen das Batching-Verhalten fest: 250 Fotos → exakt 2 Batch-Aufrufe (200 + 50); fehlende Varianten fallen auf Transform-URLs zurück statt kaputte Bilder zu rendern.

Was davon auf jedes Projekt übertragbar ist

  • Lazy Loading versteckt, wo die Zeit wirklich hingeht. Die Bilder waren lazy; die 2.408 Signierungs-Round-Trips nicht. Profilen Sie den Request, der die Liste liefert — nicht nur die Assets.
  • N+1 ist kein reines Datenbank-Problem. Jeder Pro-Element-Aufruf an einen internen HTTP-Service (URL-Signierung, Feature-Flags, Berechtigungs-Checks) ist derselbe Bug in anderem Gewand. Batch-Endpoints existieren fast immer — sie werden nur nicht standardmäßig genutzt.
  • Signierte URLs deaktivieren stillschweigend das HTTP-Caching. Einplanen oder die URL-Erzeugung pro TTL-Fenster deterministisch machen.
  • Bildgröße an Render-Größe koppeln. 300-px-WebP für 250-px-Zellen, nicht 800 px „zur Sicherheit".
  • Vorberechnen, was Nutzer vorhersehbar anfragen. Echtzeit-Bildtransformationen auf einer 2-Kern-Maschine funktionierten genau bis zum ersten echten 1.246-Foto-Album.

Wie es zu den 10-Sekunden-Thumbnail-Timeouts kam — und welche Sicherheitsarchitektur dahintersteckt — steht im zweiten Teil dieser Serie: DSGVO-Fotoplattform für Schulen: Was Sicherheit wirklich kostet. Warum Ladezeit generell über Anfragen entscheidet, erklärt unser Artikel Warum langsame Websites Kunden kosten.

Häufige Fragen

Was ist ein N+1-Problem bei signierten URLs?

Ein N+1-Problem entsteht, wenn für jedes Element einer Liste ein eigener Request ausgeführt wird, statt alle auf einmal zu laden. Bei privaten Foto-Buckets heißt das: Jede signierte URL wird einzeln beim Storage-Service angefragt. Bei 1.204 Fotos mit je 2 Bildgrößen sind das 2.408 HTTP-Requests pro Seitenaufruf — die Batch-API (createSignedUrls) erledigt dasselbe in ~12 Requests.

Warum ist eine Bildergalerie trotz Lazy Loading langsam?

Lazy Loading spart nur Bild-Bytes, nicht die Zeit, bis die Bild-URLs überhaupt beim Browser ankommen. Wenn der Server pro Foto einzelne Signierungs-Requests ausführt, sieht der Nutzer sekundenlang eine leere Seite — bevor das erste Bild geladen werden kann. Gemessen werden muss der Request, der die Liste liefert, nicht nur die Assets.

Welche Bildgröße gehört in ein Galerie-Grid?

Eine Grid-Zelle, die mit 200–400 px gerendert wird, braucht eine ~300-px-Variante (WebP, ~32–47 KB), keine 800-px- oder Original-Datei. In diesem Projekt senkte der Wechsel von 800 px auf 300 px die Bildbandbreite beim Scrollen um den Faktor 5–10: von ~150 MB auf ~25 MB für ein 1.204-Foto-Album.

Was bringt content-visibility: auto bei langen Listen?

content-visibility: auto lässt den Browser Layout- und Paint-Arbeit für Elemente außerhalb des Viewports überspringen. Zusammen mit contain-intrinsic-size liefert das geschätzt ~80 % des Nutzens einer virtualisierten Liste — mit einer CSS-Zeile statt einem Umbau auf Bibliotheken wie react-virtuoso.