{ }
GET
API Performance · Projection · Streaming · Compression · Caching
REST API Performance:
Datenmenge, Projection, Streaming und Compression

Die häufigste Ursache für langsame REST APIs ist nicht der Server, sondern die übertragene Datenmenge. Projection reduziert die Response-Größe auf das, was der Client tatsächlich braucht. Streaming überträgt große Datasets ohne Speicher-Overhead. Compression halbiert die Netzwerklast. HTTP-Caching eliminiert redundante Requests vollständig.

19 Min. Lesezeit Projection · Sparse Fieldsets · JSON Streaming · Gzip · Brotli · ETag · Cache-Control Symfony 7 · PHP 8.4 · Symfony Serializer · HTTP/2

1. Das Datenmengenproblem: Over-fetching und Under-fetching

Over-fetching ist die häufigste Performance-Ursache in REST-APIs: ein Endpoint liefert immer alle Felder einer Ressource, auch wenn der Client nur drei davon braucht. Ein User-Objekt mit 30 Feldern (inklusive großem bio-Textfeld und Base64-encodiertem Avatar) wird komplett übertragen, obwohl eine Dropdown-Liste nur id, name und email benötigt. Bei hundert Users in einer Liste multipliziert sich der Overhead entsprechend. Under-fetching ist das Gegenproblem: der Client muss mehrere Requests machen, um alle benötigten Daten zu bekommen, weil Beziehungsdaten nie eingebettet werden.

Beide Probleme haben strukturelle Lösungen: Projection (Feldauswahl per Query-Parameter) adressiert Over-fetching, kontrolliertes Einbetten (Embedding per Parameter) adressiert Under-fetching. Diese Mechanismen müssen nicht vollständig wie GraphQL-Feldauswahl implementiert werden – schon einfache ?fields=id,name,email-Unterstützung reduziert die Datenmenge in typischen Listenabfragen um 70–90%. Der Implementierungsaufwand ist gering; der Performance-Gewinn ist messbar.

2. Projection und Sparse Fieldsets implementieren

Sparse Fieldsets nach dem JSON:API-Muster verwenden Query-Parameter wie ?fields[products]=id,name,price zur Feldauswahl. Im Symfony-Kontext lässt sich das elegant mit Serializer-Gruppen kombinieren: statt statischer Gruppen wird eine dynamische Gruppe aus den angeforderten Feldern aufgebaut. Der Symfony-Serializer unterstützt die Übergabe von Serialization-Context pro Request, sodass die Feldauswahl ohne Architekturchanges nachgerüstet werden kann.

Wichtig: die erlaubten Felder müssen validiert werden, um Information-Disclosure zu verhindern. Ein Client darf nicht per Projection auf interne Felder zugreifen, die nicht im öffentlichen API-Schema stehen. Eine Allowlist der validen Felder pro Ressource verhindert das. Performance-Tipp: wenn Projection auf der Datenbankschicht umgesetzt wird (SELECT nur benötigte Spalten), spart man nicht nur Netzwerklast, sondern auch Datenbankbandbreite und Serialisierungszeit.

> $allowedFields Allowed fields per resource type
     */
    public function __construct(
        private readonly array $allowedFields,
    ) {}

    /**
     * Extracts and validates the requested fields from the query string.
     * Returns null if no projection is requested (return all allowed fields).
     *
     * @return list|null
     */
    public function resolve(Request $request, string $resourceType): ?array
    {
        $param = $request->query->get('fields');
        if ($param === null) {
            return null; // No projection — return all fields
        }

        $requested = array_filter(
            array_map('trim', explode(',', $param)),
            fn(string $f) => $f !== ''
        );

        $allowed = $this->allowedFields[$resourceType] ?? [];
        $valid = array_values(array_intersect($requested, $allowed));

        // Always include id for resource identification
        if (!in_array('id', $valid, true)) {
            array_unshift($valid, 'id');
        }

        return $valid ?: null;
    }

    /**
     * Builds Symfony Serializer context groups from allowed fields.
     *
     * @param list|null $fields
     * @return array
     */
    public function buildSerializerContext(?array $fields): array
    {
        if ($fields === null) {
            return ['groups' => ['api:read']];
        }

        // Map individual fields to groups: field "name" -> group "api:field:name"
        $groups = array_map(
            fn(string $f) => 'api:field:' . $f,
            $fields
        );

        return ['groups' => $groups];
    }
}

3. Pagination: Cursor-basiert vs. Offset für große Datasets

Offset-basierte Pagination (?page=5&per_page=20) ist intuitiv und einfach zu implementieren, hat aber fundamentale Performance-Probleme bei großen Datasets. Ein SELECT ... LIMIT 20 OFFSET 10000 muss die ersten 10.000 Einträge scannen, auch wenn nur 20 zurückgegeben werden. Bei einer Tabelle mit einer Million Einträgen ist Seite 50.000 praktisch nicht abfragbar. Außerdem ist Offset-Pagination nicht stabil: wenn zwischen Seite 3 und Seite 4 ein Eintrag eingefügt wird, verschiebt sich alles um eine Position.

Cursor-basierte Pagination löst beide Probleme. Der Cursor ist ein opaker String (typischerweise Base64-encoded primärer oder sekundärer Index-Wert), der den letzten gesehenen Datensatz identifiziert. Die Abfrage lautet dann WHERE id > cursor LIMIT 20 – keine Offset-Berechnung, nur ein Index-Scan. Der Response enthält einen next_cursor-Wert für die nächste Seite. Cursors sind unveränderlich: Inserts und Deletes zwischen Requests beeinflussen die Pagination nicht. Die einzige Einschränkung: kein Springen zu beliebigen Seiten – nur vorwärts und rückwärts (mit entsprechendem Cursor).

4. HTTP Streaming für große Datensätze

HTTP Streaming ermöglicht es, große Datasets zu übertragen, ohne sie vollständig im Speicher zu halten. Der Symfony StreamedResponse überträgt Daten als Stream: die Verbindung wird offen gehalten, Daten werden häppchenweise gesendet, der Client kann sofort mit der Verarbeitung beginnen. Das ist besonders wertvoll für Export-Endpoints (CSV, JSON-Lines) und für Endpoints, die Datenbankabfragen mit sehr vielen Ergebnissen streamen müssen.

Für JSON-Streaming eignet sich das JSON-Lines-Format (application/x-ndjson): jede Zeile ist ein vollständiges JSON-Objekt. Der Client kann jede Zeile sofort verarbeiten, ohne auf das Ende der Response warten zu müssen. Im Gegensatz zu einem großen JSON-Array muss der Client das JSON-Lines-Format nicht als Ganzes in den Speicher laden. Für PHP bedeutet das: while ($row = $query->fetchAssociative()) mit direktem echo json_encode($row) . "\n" und regelmäßigem flush().

productRepository->getConnection();
                $stmt = $connection->executeQuery(
                    'SELECT id, name, sku, price, stock FROM products WHERE active = 1'
                );

                $count = 0;
                while ($row = $stmt->fetchAssociative()) {
                    echo json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
                    $count++;

                    // Flush buffer every 100 rows to avoid memory accumulation
                    if ($count % 100 === 0) {
                        ob_flush();
                        flush();
                    }
                }
            },
            status: 200,
            headers: [
                'Content-Type' => 'application/x-ndjson',
                'X-Accel-Buffering' => 'no',      // Disable Nginx buffering
                'Cache-Control' => 'no-store',
                'Transfer-Encoding' => 'chunked',
            ]
        );
    }
}

5. HTTP Compression: Gzip, Brotli und Content-Encoding

HTTP-Compression ist eine der einfachsten und wirkungsvollsten Performance-Optimierungen für REST-APIs. JSON ist hochgradig komprimierbar – typischerweise auf 15–30% der unkomprimierten Größe. Ein 100 KB großer JSON-Response wird auf 15–30 KB reduziert. Bei kleinen Responses unter 1 KB überwiegt der Kompressionsoverhead; ab 1 KB ist Kompression fast immer sinnvoll. Der Client signalisiert Unterstützung über den Accept-Encoding-Header: Accept-Encoding: br, gzip, deflate. Brotli (br) ist moderner und erreicht 15–25% bessere Kompressionsraten als Gzip bei vergleichbarer Dekompressionsgeschwindigkeit.

Für Symfony: Nginx oder Apache komprimieren bereits vor der PHP-Ausführung, wenn die Konfiguration stimmt. In der Nginx-Config: gzip on; gzip_types application/json application/problem+json; brotli on;. Das ist effizienter als PHP-seitige Kompression mit ob_gzhandler. Wichtig: beim Einsatz von HTTP-Caching muss der Vary: Accept-Encoding-Header gesetzt sein, damit Caches für Gzip- und Nicht-Gzip-Clients separate Einträge halten.

6. HTTP-Caching: ETag, Cache-Control und Conditional Requests

HTTP-Caching eliminiert redundante Requests vollständig. Wenn ein Client die aktuelle Version einer Ressource bereits hat und sie sich nicht geändert hat, antwortet der Server mit 304 Not Modified – ohne Response-Body. Das spart Bandbreite und Serververarbeitungszeit. Zwei Mechanismen: ETag (Entity Tag) ist ein Fingerprint des Response-Inhalts. Last-Modified ist ein Zeitstempel der letzten Änderung. Der Client sendet bei Folge-Requests If-None-Match: "etag-value" oder If-Modified-Since: Tue, 09 May 2026 10:00:00 GMT – der Server antwortet mit 304 oder der aktuellen Version.

Cache-Control-Direktiven steuern, wie lange und von wem gecacht wird: Cache-Control: public, max-age=300 erlaubt Public Caches (CDN, Proxies) für 5 Minuten. Cache-Control: private, max-age=60 erlaubt nur Browser-Caching für 1 Minute. Cache-Control: no-store verhindert jedes Caching (für sensible Daten). s-maxage überschreibt max-age für Shared Caches (CDN) – nützlich wenn Browser- und CDN-TTL unterschiedlich sein sollen. Symfony HttpFoundation macht es einfach, diese Header korrekt zu setzen.

productRepository->find($id);
        if ($product === null) {
            return $this->json([
                'type' => 'https://mironsoft.de/errors/not-found',
                'title' => 'Product not found',
                'status' => 404,
            ], 404, ['Content-Type' => 'application/problem+json']);
        }

        // Build ETag from content hash — changes when product changes
        $etag = md5(serialize([
            $product->getId(),
            $product->getUpdatedAt()?->getTimestamp(),
        ]));

        // Check conditional request
        if ($request->getETags() && in_array('"' . $etag . '"', $request->getETags(), true)) {
            return new Response('', 304, [
                'ETag' => '"' . $etag . '"',
                'Cache-Control' => 'public, max-age=300, s-maxage=600',
            ]);
        }

        $data = [
            'id' => $product->getId(),
            'name' => $product->getName(),
            'price' => $product->getPrice(),
            'sku' => $product->getSku(),
        ];

        return new JsonResponse($data, 200, [
            'ETag' => '"' . $etag . '"',
            'Cache-Control' => 'public, max-age=300, s-maxage=600',
            'Vary' => 'Accept-Encoding, Accept-Language',
            'Last-Modified' => $product->getUpdatedAt()?->format('D, d M Y H:i:s') . ' GMT',
        ]);
    }
}

7. N+1-Problem und eager Loading in API-Responses

Das N+1-Problem ist die häufigste Datenbankperformance-Ursache in API-Endpoints: ein Query liefert N Produkte, dann werden für jedes Produkt separate Queries für Kategorie, Bilder und Preisregeln ausgeführt – insgesamt 1 + N*3 Queries. Bei N=100 sind das 301 Datenbankabfragen für eine einzige API-Response. Doctrine ORM macht dieses Problem unsichtbar durch Lazy Loading – Beziehungen werden beim Zugriff automatisch nachgeladen, ohne dass der Code es explizit anfordert.

Die Lösung ist Eager Loading: Doctrine-Abfragen mit ->leftJoin()->addSelect() laden alle benötigten Beziehungen in einem einzigen Query. Symfony Profiler und die Doctrine-SQL-Logger zeigen, wie viele Queries ein Endpoint ausführt. Mehr als 5 Queries für eine API-Response ist ein Warnsignal. Redis-Caching auf Aggregat-Ebene (vollständige Produktdaten inklusive Beziehungen) kann die Datenbankzugriffe weiter reduzieren, erfordert aber eine saubere Cache-Invalidierungsstrategie bei Änderungen.

8. Vergleich der Performance-Strategien

Jede Performance-Strategie hat ihren optimalen Einsatzbereich. Die Wahl hängt vom Use Case, der Datenmenge und der akzeptablen Implementierungskomplexität ab.

Strategie Einsatzbereich Typische Verbesserung Implementierungskomplexität
Projection / Fieldsets Listen-Endpoints mit vielen Feldern 70–90% weniger Datenmenge Mittel
HTTP Compression Alle JSON-Endpoints ab 1 KB 70–85% weniger Bandbreite Gering (Nginx-Config)
HTTP Caching (ETag) Selten ändernde Ressourcen 100% Einsparung bei 304 Mittel
Cursor Pagination Große Datasets (>10.000 Einträge) Konstante Query-Zeit Mittel
HTTP Streaming Export-Endpoints, sehr große Listen Kein Speicher-Overhead Gering (StreamedResponse)

9. Zusammenfassung

REST API Performance ist primär ein Problem der übertragenen Datenmenge, nicht der Server-Verarbeitungszeit. Projection reduziert Over-fetching durch Feldauswahl per Query-Parameter – einfach zu implementieren, sofort messbar. HTTP Compression halbiert die Netzwerklast ohne Code-Änderung, wenn Nginx korrekt konfiguriert ist. HTTP Caching mit ETag eliminiert redundante Transfers vollständig für unveränderte Ressourcen. Cursor-basierte Pagination skaliert auf Millionen von Einträgen ohne Offset-Degradation. Streaming ermöglicht Export-Endpoints ohne Speicher-Overhead. Das N+1-Problem wird durch Eager Loading und Query-Profiling erkannt und eliminiert.

Der praktische Weg: zuerst messen (Symfony Profiler, HTTP-Analyzer), dann optimieren. Die häufigsten Wins sind Compression (Nginx-Config) und ETags (3 Zeilen Code), die sofort große Auswirkungen haben. Projection und Cursor Pagination erfordern mehr Implementierungsarbeit, sind aber für skalierbare APIs langfristig unverzichtbar.

REST API Performance — Das Wichtigste auf einen Blick

Projection

?fields=id,name,price reduziert Datenmenge um 70–90%. Allowlist für Feldzugriff Pflicht. DB-SELECT-Optimierung für maximalen Effekt.

Compression

Brotli/Gzip in Nginx konfigurieren. ab 1 KB Response sinnvoll. Vary: Accept-Encoding bei Caching setzen. 70–85% Bandbreiteneinsparung.

HTTP Caching

ETag als Content-Hash. Cache-Control: public, max-age für CDN. 304 Not Modified für unveränderte Ressourcen. Vary korrekt setzen.

Pagination & Streaming

Cursor-Pagination für >10.000 Einträge. StreamedResponse + NDJSON für Export. Kein Speicher-Overhead, sofortiger Client-Start.

10. FAQ: REST API Performance

1Was ist Over-fetching?
API liefert immer alle Felder, auch wenn nur wenige benötigt. Lösung: Sparse Fieldsets mit ?fields=id,name,price. Reduziert Datenmenge um 70–90%.
2Offset vs. Cursor Pagination?
Offset: wird langsamer bei großen Datasets. Cursor (WHERE id > x): konstante Query-Zeit, stabil gegenüber Inserts, skaliert auf Millionen.
3Wie viel spart HTTP Compression?
70–85% Bandbreiteneinsparung bei JSON. Brotli 15–25% besser als Gzip. Ab 1 KB Response lohnend. In Nginx konfigurieren, nicht in PHP.
4HTTP Caching mit ETag?
Server schickt ETag (Content-Hash). Client schickt If-None-Match. Unverändert: 304 ohne Body. Spart Bandbreite und Serverarbeit vollständig.
5HTTP Streaming Einsatzbereich?
Export-Endpoints, große Collections. StreamedResponse streamt Zeile für Zeile ohne Speicher-Overhead. Client kann sofort verarbeiten.
6N+1-Problem erkennen?
Symfony Profiler zeigt Query-Anzahl. Mehr als 5 Queries ist Warnsignal. Lösung: Eager Loading mit JOIN. Redis-Cache für aggregierte Daten.
7Wichtige Cache-Control-Direktiven?
public,max-age=300 für CDN. private für nutzerspezifisch. s-maxage für CDN-TTL. no-store für sensible Daten. Vary: Accept-Encoding immer bei Compression.
8Compression in PHP oder Nginx?
Nginx ist effizienter und läuft außerhalb des PHP-Prozesses. Nginx: gzip on; gzip_types application/json; brotli on (mit Modul).
9Sparse Fieldsets sicher implementieren?
Allowlist pro Ressource. Gegen Allowlist validieren. Unbekannte Felder ignorieren. Interne Felder nie in Allowlist. id immer inklusive.
10NDJSON vs. normales JSON-Array?
NDJSON: jede Zeile vollständiges JSON-Objekt. Client kann sofort parsen. JSON-Array: muss vollständig empfangen werden vor dem Parsen. Für Streaming immer NDJSON.