HTTP/2 Server Push

Conceptueel gezien is er weinig veranderd: begrippen als requests, responses, headers en URL’s zijn gehandhaafd, en ook rollen als proxy, gateway en tunnel houden dezelfde betekenis. De vernieuwing zit hem met name in het transportmechanisme, waardoor browsers efficienter meer resources parallel kunnen ontvangen. Hedzer Westra schreef in de vorige editie van dit magazine (nummer 4, jaargang 2016) al over de details en de gevolgen van deze veranderingen.
Een nieuw concept dat met HTTP/2 is geïntroduceerd is ‘Server Push’: hiermee kan een server speculatief alvast ‘extra’ resources beginnen te verzenden, ook al heeft de client hier nog niet om gevraagd. Dat levert interessante performance trade-offs op, waarvan we er een aantal zullen bespreken.

Zonder Server Push

Om de verbetering in het parallel laden van resources te demonstreren is de 'tiles'-demo ondertussen een klassieker. Het concept is eenvoudig: we laden een HTML-pagina met daarop een afbeelding die bestaat uit losse plaatjes ('tiles'), die allemaal apart als resource worden opgehaald.

Zeker wanneer we de latency van de requests kunstmatig nog wat verhogen is het effect spectaculair: laden we 20 resources met een latency van een seconde en maximaal 5 tegelijk (zoals bij HTTP/1.1), dan duurt dit zo 5 à 6 seconden. Kunnen we met HTTP/2 alle plaatjes parallel ophalen, dan zijn we na ruim 2 seconden klaar.

In afbeelding 1 zien we het bekende gedrag van HTTP/1.1 schematisch weergegeven, met een 'kunstmatige' latency van 1 seconde. Eerst wordt de HTML-pagina opgehaald, daarna de 20 losse plaatjes, maar die slechts met maximaal 5 parallele requests. Afbeelding 2 laat zien wat er gebeurt als we HTTP/2 gebruiken, en de parallele requests over 1 verbinding kunnen multiplexen: een flinke vooruitgang.

Server Push

Met 'Server Push' kunnen we de demo nog mooier maken. In de metingen hierboven moesten we een seconde wachten op de HTML, waarna we de 20 plaatjes gingen ophalen. HTTP/2 biedt de server de mogelijkheid op eigen initiatief al te beginnen met het sturen van resources, nog voordat de browser erom gevraagd heeft. De browser zal ze in de cache zetten. Wanneer de plaatjes nodig zijn kunnen ze direct vanuit de cache geladen worden.

Zo kan het dat we in de demo met 'Server Push' minder dan 2 seconden nodig hebben om zowel de pagina als alle plaatjes te laden: zoals in afbeelding 3 goed te zien is hebben we voor de eerste request nog last van de latency van een seconde, maar omdat de server meteen kan beginnen met het sturen van alle resources zijn we onder de streep sneller klaar.

Zeker op mobiele netwerken, waar de latency onvoorspelbaar is, kan dit een realistisch scenario zijn.

Scripts

Helaas houden we onszelf met deze demo toch behoorlijk voor de gek. Veel hedendaagse pagina's staan namelijk bol van de javascript. Afhankelijk van de gebruikte technologie kan het zijn dat je pagina nog niet bruikbaar – of zelfs nog niet zichtbaar! – is tot de javascript is uitgevoerd.

De volgorde waarin scripts precies worden geladen en uitgevoerd is een complex geheel: kijk om een idee te krijgen eens naar afbeelding 4, afkomstig uit hoofdstuk 4.12 van de WHATWG spec. Sterk vereenvoudigd zijn er 2 momenten echt belangrijk bij het laden van de pagina: het moment van het 'DOMContentLoaded'-event en dat van het 'load'-event. 'DOMContentLoaded' wordt gevuurd op het moment waarop de 'essentiele' delen van de pagina geladen zijn: de HTML zelf en de scripts, met uitzondering van scripts die asynchroon kunnen lopen. Andere resources, zoals CSS en plaatjes, hoeven nog niet geladen te zijn. Wanneer ook die verwerkt zijn vuurt het 'load'-event pas.

'DOMContentLoaded' correspondeert met de bekende '$(…)'-constructie van jQuery, 'load' met het ouderwetse 'body onload'. De metingen die we tot nu toe gedaan hebben kijken dus eigenlijk naar de tijd die het kost om tot 'load' te komen, terwijl de tijd tot 'DOMContentLoaded' minstens zo interessant is.

Wil je je eigen metingen doen, dan komen in de browser de waarden uit het performance.timing'-object uit de Navigation Timing API goed van pas, welke tegenwoordig door veel browsers wordt ondersteund.

Server Push heeft geen invloed op de volgorde waarin resources door de browser worden behandeld: de ge-push-de resources worden in de cache gezet tot het moment waarop ze nodig zijn. Wel levert het ontvangen en in de cache plaatsen van de ge-push-de resources wel degelijk netwerk- en CPU-load op. Hierdoor is in afbeeldingen 5 en 6 duidelijk een trade-off te zien: weliswaar zorgt Server Push hier inderdaad voor een flink snellere 'load', we 'betalen' hiervoor met een vertraging in de tijd tot 'DOMContentLoaded'.

Zonder 'extra' latency

Laten we de seconde 'extra latency' wegvallen dan levert dat nog een minder rooskleurig beeld op: in afbeeldingen 7 en 8 zien we een situatie waarin de 'DOMContentLoaded' nog steeds wordt vertraagd door de extra belasting veroorzaakt door de ge-push-de resources, maar we krijgen hier niet eens een snellere 'load' voor terug: soms is deze zelfs trager.

Het is dus zaak per applicatie kritisch te kijken voor welke resources Server Push echt een verbetering oplevert – rücksichtslos zo veel mogelijk resources pushen kan een averechts effect hebben.

Stream priorities

De HTTP/2-standaard kent een mechanisme waarmee verschillende streams verschillende prioriteiten toegekend krijgen. Daarmee zou het effect dat we in de vorige paragraaf zagen wellicht verminderd kunnen worden – hoewel een en ander natuurlijk sterk afhangt van de situatie en de implementatie aan zowel client- als serverkant. Goed meten en afwegen blijft dus een must.

Caching

Een resource uit de cache laden zal doorgaans sneller zijn dan van het web. Aangezien bij Server Push de server het initiatief neemt bestaat de kans dat een resource wordt gestuurd die al in de cache staat. Weliswaar kan de client een stream afbreken, maar dan zijn er al pakketten gestuurd en dus resources verspild.

Een voorgestelde oplossing voor dit probleem zijn Cache Digests: bij het maken van de request naar de hoofdpagina kan de browser een header meesturen met daarin informatie over de huidige vulling van de cache. Op basis daarvan kan de server beslissen bepaalde resources wel of niet te pushen.

Een naïeve implementatie van zo'n header zou al snel groot worden. Daarom wordt gebruik gemaakt van een zogenaamde 'Golomb-coded set' (GCS). Dit is een probabilistische datastructuur, vergelijkbaar met een Bloom filter. Kort gezegd zijn dit datastructuren die veel efficienter zijn (meestal in termen van geheugengebruik), ten koste van een (hopelijk voorspelbaar/klein) verlies aan precisie. Omdat het acceptabel is af en toe een resource onnodig te pushen (of te moeten opvragen) is zo'n datastructuur hier geschikt.

Dit is een actief onderzoeksgebied waarvan nog weinig implementaties bekend zijn. Of de extra complexiteit die deze oplossing toevoegt opweegt tegen de (toch al zo moeilijk aan te tonen) performancewinst valt te nog bezien.

Conclusie

Zonder tuning kan HTTP/2 een mooie en relatief laagdrempelige manier zijn om pagina's sneller te laten laden. Met features zoals Server Push kun je nog een stap verder gaan – maar pas op: als je niet uitkijkt doet het meer kwaad dan goed.