Frontend Performance Optimiziation

Step by Step with Symfony2

SunshinePHP, Feb 8th 2013 © David Buchmann, Liip AG

About David Buchmann

Twitter: @dbu

David is a senior developper at Liip SA, specializing in Symfony2. He happens to also be a certified Scrum Master and sometimes enjoys doing the scrum master or product owner role for a project.

Liip is doing custom web development with PHP in Switzerland.

What makes a website slow?

What makes a website slow?


Measure - change - measure

Jordi Boggiano



Understand the effects of your changes.



Remember:

«Premature optimization is the root of all evil»

Donald E. Knuth

I am going to talk about

  1. Frontend performance analysis and basic tips
  2. Assetic to get serious about css and js optimization
  3. Caching
  4. Varnish and ESI

I am not going to talk about

Overview

  1. Frontend performance analysis and basic tips
  2. Assetic to get serious about css and js optimization
  3. Caching
  4. Varnish and ESI

Tools and helpers

The demo application


github.com/dbu/symfony-speed

Demo application

Measure

Measure

About 10 seconds until the user sees a page

Optimization: Move javascript to the bottom

# layout.html.twig
@@ -6,8 +6,6 @@
    {% include 'DbuCoreBundle::stylesheets.html.twig' %}
-   {% include 'DbuCoreBundle::javascripts.html.twig' %}
    <link rel="shortcut icon" href="{{ asset('favicon.ico') }}" >
  </head>

@@ -91,5 +89,11 @@
    </div>
+   {# javascript at the bottom: browser waits with rendering
+      until all files referenced before are loaded
+   #}
+   {% include 'DbuCoreBundle::javascripts.html.twig' %}

</body>
        

Measure:
We just doubled the speed the user perceives!

Browser starts rendering after as soon as it has all CSS files, no need to wait for javascript to load.

Page renders after about 5 seconds

Measure:
We just doubled the speed the user perceives!

Browser starts rendering after as soon as it has all CSS files, no need to wait for javascript to load.

Page renders after about 5 seconds

There is still a huge number of requests

Overview

  1. Frontend performance analysis and basic tips
  2. Assetic to get serious about css and js optimization
  3. Caching
  4. Varnish and ESI

Enter assetic: Combine js files, combine css files

Using assetic

Measure:
Reduced the number of requests from 62 to 9

Measure:
Reduced the number of requests from 62 to 9

But there are lots of image files
(a real page could have many more)

CSS Sprites


background: url('../images/sprites.png')
no-repeat -2px -43px;

CSS Sprites

/* main.css */

.sprite {
    display:block;
    background-repeat:no-repeat;
    background-image:url(/assets/images/sprite.png);
}

.logo {
    width:336px;
    height:63px;
}
/* generated sprites.css */

.logo {background-position:-160px 0px}
        

Measure: Down to 4 requests

Measure: Down to 4 requests

But still large files, downloaded 720 KB in total

YUI compressor

# config_prod.yml
assetic:
  filters:
    yui_css:
      jar: "%kernel.root_dir%/Resources/java/yuicompressor.jar"
      apply_to: "\.css$"
    yui_js:
      jar: "%kernel.root_dir%/Resources/java/yuicompressor.jar"
      apply_to: "\.js$"
            

Note

YUICompressor has been discontinued by the YUI team. You can still get the compiled .jar from the github page.

Measure: Down to 2.5 seconds

Measure: Down to 2.5 seconds

Takes a lot of time to download things


What is the fastest way to download data?


When you don't need to download it at all!

Overview

  1. Frontend performance analysis and basic tips
  2. Assetic to get serious about css and js optimization
  3. Caching
  4. Varnish and ESI

Know your tools

Caching

Server can tell browser how long data is valid

Server: Apache/2.2.16 (Ubuntu)
cache-control: max-age=600, public
last-modified: Wed, 02 May 2012 09:12:17 GMT
            

With last-modified, browser can ask server if document was modified since it last saw it

If-Modified-Since: Wed, 02 May 2012 09:12:17 GMT

Server:
304 Not Modified
            

Symfony2 sessions and cookies

firewalls:
  name:
    logout:
      path:   /logout
      target: /
      invalidate_session: true
      delete_cookies:
        PHPSESSID: { path: null, domain: null }
            

Configure Apache to cache aggressivly

ExpiresActive On
ExpiresByType text/css  "access plus 3 months"
ExpiresByType text/javascript "access plus 3 months"
ExpiresByType application/javascript "access plus 3 months"
ExpiresByType image/gif "access plus 1 day"
ExpiresByType image/png "access plus 1 day"
ExpiresByType image/jpg "access plus 1 day"
ExpiresByType image/jpeg "access plus 1 day"
            

Cache busting


# app/config.yml
framework:
  templating:
    assets_version: v1
            
<!-- generated HTML -->
<link rel="stylesheet" href="/css/style.css?v1" type="text/css"/>
...
<script src="/js/scripts.js?v1"></script>
            

Have Symfony provide cache headers

// DefaultController::indexAction
$response = $this->render('DbuCoreBundle:Default:index.html.twig', array());
$response->setMaxAge(600);
$response->setPublic();
return $response;
            

Add such code to every action that renders a response.

Or annotate every action with a @Cache comment.

LiipCacheControlBundle

# config.yml
liip_cache_control:
    rules:
        # Make sure login and logout are not cached at all
        -
            path: ^/(login|login_check|logout)
            controls:
                private: true
                max_age: 0
        # Cache the homepage for 10 minutes
        -
            path: ^/$
            vary: Cookie
            controls:
                public: true
                max_age: 600
        # Cache every other page for 1 hour
        -
            path: ^/
            vary: Cookie
            controls:
                public: true
                max_age: 3600
            
Clear the Symfony cache whenever you change values here

When cache can be used: Really fast

When cache can be used: Really fast

But this does not help on first page load

Overview

  1. Frontend performance analysis and basic tips
  2. Assetic to get serious about css and js optimization
  3. Caching
  4. Varnish and ESI

What is a reverse proxy again?

Lets have a cache at server side: Varnish


Debug hints

Warning!


Varnish does what you tell it


Think carefully and test thoroughly

Explicit cache invalidation


With LiipCacheControlBundle

# app/config.yml
liip_cache_control:
  varnish:
    domain: http://performance.lo
    ips: 10.0.0.10, 10.0.0.11 # comma separated list of ips, or an array of ips
    port: 80  # port Varnish is listening on for incoming web connections
            
// CommentsController::postAction
// the page changed, send a purge request for this url
...
$varnish = $this->container->get('liip_cache_control.varnish');
$varnish->invalidatePath($this->generateUrl('home'));
...
            
# varnish.vcl
sub vcl_recv {
    if (req.request == "PURGE") {
        # should check if allowed, i.e. IP filter
        purge("req.url ~ " req.url);
        error 200 "Success";
    }
    ...
            

Measure: We shaved another 500ms from the request

Measure: We shaved another 500ms from the request

But one cache per user (form csrf, login)

Use Edge Side Includes

Enable ESI in Symfony

# app/config/config.yml
framework:
  esi: { enabled: true }
# app/config/routing.yml
_internal:
  resource: "@FrameworkBundle/Resources/config/routing/internal.xml"
  prefix:   /_internal
            
Make sure either your webserver is only reachable by Varnish or add access restriction to the Varnish server IP for /_internal
index.html.twig
-   {% render 'DbuCoreBundle:Comments:comments' %}
+   {% render 'DbuCoreBundle:Comments:comments' with {}, {'standalone': true} %}

layout.html.twig
    <li class=login>
-       {% render 'DbuCoreBundle:User:showLoginBox' %}
+       {% render 'DbuCoreBundle:User:showLoginBox' with {}, {'standalone': true} %}
    </li>
            

Adjust cache settings

// UserController::showLoginBoxAction
$response = $this->render('DbuCoreBundle:User:loginBox.html.twig', array());
$response->setVary('Cookie', false);
$response->setMaxAge(0);
$response->setPrivate();
return $response;
            

Purge just the comments fragment

// CommentsController::commentsAction
$response = $this->render(
    'DbuCoreBundle:Comments:comments.html.twig',
    array('comments' => $this->getComments())
);
$response->setMaxAge(3600);
$response->setPublic();
return $response;
            
$varnish = $this->container->get('liip_cache_control.varnish');
$kernel = $this->container->get('http_kernel');
$varnish->invalidatePath(
    $kernel->generateInternalUri('DbuCoreBundle:Comments:comments')
);
            

Note: generateInternalUri is helpful but does not check if controller exists

Check if ESI is activated

                david# app/console cache:clear --env=prod --no-debug

                david# curl -H "Surrogate-Capability: abc=ESI/1.0" http://performance.lo/
            
...
<esi:include src="/_internal/secure/DbuCoreBundle%3AUser%3AshowLoginBox/none.html" onerror="continue" />
...
<esi:include src="/_internal/secure/DbuCoreBundle%3AComments%3Acomments/none.html" onerror="continue" />
...
            

Enable ESI in Varnish

sub vcl_recv {
    set req.http.Surrogate-Capability = "abc=ESI/1.0";
    ...
    # try to lookup even if there is a cookie
    return (lookup);
}
            
sub vcl_fetch {
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true; # just "esi;" for varnish < 3.0
    }
    ...
    if (beresp.http.Vary) {
        return (pass);
    }
}
            
sub vcl_deliver {
    if (! req.url ~ ".*\.(css|js|png|gif|jpg|ttf)(\?.*)?$") {
        set resp.http.Vary = "Cookie";
        # if-modified-since will only confuse us, remove date
        unset resp.http.Last-Modified;
    }
}
            

Conclusions

Look at all the code used for this presentation:
github.com/dbu/symfony-speed

Outlook: Assetic has many more goodies

Mix different input formats:

{% stylesheets output="assets/style.css"
    'path/to/Resources/css/yahoo-reset.css'
    'path/to/Resources/less/main.less'
    'path/to/Resources/less/misc.less'
%}
  &lt;link href="{{ asset_url }}" media="screen"
    type="text/css" rel="stylesheet" /&gt;
{% endstylesheets %}
            

Outlook: Varnish is a powerful tool

Outlook: Where to go from here

Content delivery network (cdn)

Read what others say

Thank you!



Questions / Input / Feedback ?



joind.in/8016 | @dbu