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, und 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.

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, denn 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 ≤ 200Fü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.
Die Messwerte: vorher gegen nachher
| Metrik | Vorher | Nachher |
|---|---|---|
| Storage-API-Aufrufe pro Album-Load | 2.408 | ~12 |
| API-Antwort, 1.204 Fotos (kalt) | mehrere Sekunden | 0,98 s |
| API-Antwort, 1.204 Fotos (warm) | — | 0,64 s |
| Kindbezogene Galerie (26 Fotos) | — | 0,31 s / 21.661 Bytes |
| Einzelnes Thumbnail | 10-s-Timeouts | HTTP 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, und das mit einer CSS-Zeile statt einem Umbau auf Bibliotheken wie react-virtuoso.
Quellen & Werkzeuge
- Produktionsmessungen dsgvoschulfotos.de, 10.–12. Juni 2026 (curl-Timings inkl. TLS, PostgreSQL-Abfragen, Storage-Logs)
- Supabase Storage: createSignedUrls(), Batch-Signierung
- web.dev: content-visibility, Rendering-Performance für lange Listen
- MDN: CSS content-visibility
- imgproxy: On-the-fly-Bildtransformation (hier nur noch als Fallback)
