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?
- Inefficient application code
- Response time
- Bandwith
- Browser side rendering
What makes a website slow?
- Inefficient application code
- Response time
- Bandwith
- Browser side rendering
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
- Frontend performance analysis and basic tips
- Assetic to get serious about css and js optimization
- Caching
- Varnish and ESI
I am not going to talk about
- PHP code optimization
- PHP accelerators like APC
- Webserver optimization
Overview
- Frontend performance analysis and basic tips
- Assetic to get serious about css and js optimization
- Caching
- Varnish and ESI
Tools and helpers
- firebug / chrome debug toolbar
- YSlow: yslow.org
- netem (linux): Simulate slow network
$ tc qdisc add dev lo root netem delay 300ms 30ms loss 0.10%
Caution: This will affect everything on your machine
- Sloppy: www.dallaway.com/sloppy/
- Decrease Apache performance: SetEnv no-gzip, KeepAlive Off
- Decrease Symfony2 performance: twig > cache: false
Demo application
- Controller with usleep
- Artificially bloated number and size of javascript and css
- But big projects can reach such numbers
- In-memory login and a form to show interaction
Measure
- 2 seconds to load HTML page
- Tons of CSS files
- Tons of JS files
- Page is rendered after about 10 seconds
- Once rendering is done, requests the images that are visible
Measure
- 2 seconds to load HTML page
- Tons of CSS files
- Tons of JS files
- Page is rendered after about 10 seconds
- Once rendering is done, requests the images that are visible
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
- Frontend performance analysis and basic tips
- Assetic to get serious about css and js optimization
- Caching
- Varnish and ESI
Enter assetic: Combine js files, combine css files
Using assetic
- Install it in Symfony 2.0, included by default in Symfony 2.1
- Configure assetic in config.yml
- Use {% javascripts %} and {% stylesheets %} to combine files
- Production: use app/console assetic:dump --env=prod to generate files
- Development environment:
- either use_controller: true in config_dev.yml
- or app/console assetic:dump --watch
Note: both will only update the file if direct source is modified. Files included by one of the files need cache:clear
Measure:
Reduced the number of requests from 62 to 9
- Only one request for CSS and JS each
- Page shows after about 4 seconds
- Older browsers only do 4 requests in parallel to the same domain, difference is much bigger there
- We won another second!
Measure:
Reduced the number of requests from 62 to 9
- Only one request for CSS and JS each
- Page shows after about 4 seconds
- Older browsers only do 4 requests in parallel to the same domain, difference is much bigger there
- We won another second!
But there are lots of image files
(a real page could have many more)
CSS Sprites
- Combine layout images into one file
- Use CSS to show right part of image
background: url('../images/sprites.png')
no-repeat -2px -43px;
CSS Sprites
- No native sprite support in assetic (yet)
- Unless you use scss/sass, where you have compass
- For the demo I built SpritesBundle for Pierre Minnieurs Sprites library
- Generate a CSS and a combined image from folders of images
- Translate image names into CSS classes to position the sprite
- Use app/console sprites:generate during deployment
- TODO: If you want to use sprites in your next project, integrate the bundle more tightly with Assetic
/* 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
- This will reduce the number of requests on the server
- It will make the page look nice faster
- But won't change the time until rendering starts
Measure: Down to 4 requests
- This will reduce the number of requests on the server
- It will make the page look nice faster
- But won't change the time until rendering starts
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
- Overall download size reduced by 50%
- CSS file reduced from 85 KB to 12 KB
(I put lots of comments into the CSS to make them bigger)
- JS file reduced from 617 KB to 325 KB
- Enabling gzip on server would reduce download size some more
Measure: Down to 2.5 seconds
- Overall download size reduced by 50%
- CSS file reduced from 85 KB to 12 KB
(I put lots of comments into the CSS to make them bigger)
- JS file reduced from 617 KB to 325 KB
- Enabling gzip on server would reduce download size some more
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
- Frontend performance analysis and basic tips
- Assetic to get serious about css and js optimization
- Caching
- Varnish and ESI
Know your tools
- Follow a link: Use cache if allowed, otherwise if-modified-since
- ctrl-r or reload button in firefox / chrome: Sends if-modified-since
- Force reload: Make sure the browser ignores all local caches
- Firefox: shift-ctrl-R
- Chrome: shift-ctrl-R or shift-F5
- Clear browser cache (more effort and affects all sessions)
- Debug response headers:
curl -I http://performance.lo/css/style.css
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
- Cache static assets for a long time (in Apache config)
- Cache variable content for short time
- "Vary: Cookie" header if content depends on php session
Symfony2 sessions and cookies
- Recent bugfix: Symfony only starts session when needed
- After logout, use delete_cookies handler
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
- Very long cache lifetime for assets
- Append ?version to asset links
- Change query string to make browser not use the cache
# 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
- 5 ms for the page, little more for JS and CSS
- Browser rendering takes about 100 ms
When cache can be used: Really fast
- 5 ms for the page, little more for JS and CSS
- Browser rendering takes about 100 ms
But this does not help on first page load
Overview
- Frontend performance analysis and basic tips
- Assetic to get serious about css and js optimization
- Caching
- Varnish and ESI
What is a reverse proxy again?
Lets have a cache at server side: Varnish
- Reverse proxy
- Configured in specific configuration language: VCL
- Hook for each step
- Configuration transformed to C and compiled at Varnish startup
- Can also loadbalance and handle short downtimes of webserver
Debug hints
- Restart Varnish to start with empty cache
- Varnish does not play nicely with a netem delay of more than 200ms
Warning!
Varnish does what you tell it
Think carefully and test thoroughly
Explicit cache invalidation
- Web application sends long cache lifetime to varnish
- Explicitly tells varnish to invalidate cached URLs
- If you can't tell when content changes: Set low lifetime
With LiipCacheControlBundle
- Use reverse_proxy_ttl to send X-Reverse-Proxy-TTL header
- Configure varnish to use that header to set cache time to life
- Send purge requests with liip_cache_control.varnish
# 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
- Save the time Symfony takes to render the HTML page
- This will reduce the load on the webserver
- Only on second request, when Varnish knows page
Measure: We shaved another 500ms from the request
- Save the time Symfony takes to render the HTML page
- This will reduce the load on the webserver
- Only on second request, when Varnish knows page
But one cache per user (form csrf, login)
Use Edge Side Includes
- Instead of render everything, Symfony creates special links for elements
- Varnish fetches and caches elements separatly
- Different caching rules may apply to the elements
- Only some elements vary on cookie
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
- Remove vary: Cookie from general cache rules
- Exclude _internal from cache rules
- Set cache rules for the ESI - exposed actions
// 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
- Simple, but effective:
- Javascript at the bottom
- Assetic to combine and compress css and javascript
- Use a sprite library for GUI elements
- More involved: Cache rules and simple Varnish
- For high traffic: Dive into Varnish and ESI
Look at all the code used for this presentation:
github.com/dbu/symfony-speed
Outlook: Assetic has many more goodies
- filters to render popular css extensions: less, scss/sass, ...
- filters for popular js extensions: coffeescript, sprockets, ...
- add your own filters
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'
%}
<link href="{{ asset_url }}" media="screen"
type="text/css" rel="stylesheet" />
{% endstylesheets %}
Outlook: Varnish is a powerful tool
Outlook: Where to go from here
Content delivery network (cdn)
- Have a separate server that is tuned to deliver static files
- Supported by assetic
- akamai: resolving dns to use a server near the user for lowest network latency and speed of light
Read what others say
Thank you!
Questions / Input / Feedback ?