Speed up Django with far-future expires, compression and other best practices
As a web developer with a shoddy rural internet connection, I'm always interested in speeding up my sites. One technique for doing this is far-future expires — i.e. telling the browser to cache media requests forever, then changing the uri when the media changes. In this article, I outline how to implement this and several other techniques in django.
Goals
- Reduce http requests for css and js files to a bare minimum.
- Add far-future-expires headers to all static content
- Gzip all css and js content
- Reduce css/js filesize by minification
The django-compress App
First up, I installed the django-compress app. I was about to build my own solution when I realised this one did exactly what I needed — gotta love the django community. Configuring is straightforward — the project wiki has articles on installation, configuration, and usage..
I copied the compress/ directory into my django/library/ folder (which is on the python path) and added "compress" to my INSTALLED_APPS.
Apache Configuration
Once I had django-compress up and running, I had achieved goal #1. To achieve #2 and #3 I needed to configure apache to send the right headers along with each request. To do this, I put the following directive in my httpd.conf file:
<DirectoryMatch /path-to-django-projects/([^/]+)/media> Order allow,deny Allow from all # Insert mod_deflate filter SetOutputFilter DEFLATE # Netscape 4.x has some problems... BrowserMatch ^Mozilla/4 gzip-only-text/html # Netscape 4.06-4.08 have some more problems BrowserMatch ^Mozilla/4\.0[678] no-gzip # MSIE masquerades as Netscape, but it is fine BrowserMatch \bMSIE !no-gzip !gzip-only-text/html # Don't compress images SetEnvIfNoCase Request_URI \ \.(?:gif|jpe?g|png)$ no-gzip dont-vary # Make sure proxies don't deliver the wrong content Header append Vary User-Agent env=!dont-vary # MOD EXPIRES SETUP ExpiresActive on ExpiresByType text/javascript "access plus 10 year" ExpiresByType application/x-javascript "access plus 10 year" ExpiresByType text/css "access plus 10 years" ExpiresByType image/png "access plus 10 years" ExpiresByType image/x-png "access plus 10 years" ExpiresByType image/gif "access plus 10 years" ExpiresByType image/jpeg "access plus 10 years" ExpiresByType image/pjpeg "access plus 10 years" ExpiresByType application/x-flash-swf "access plus 10 years" ExpiresByType application/x-shockwave-flash "access plus 10 years" # No etags as we're using far-future expires FileETag none </DirectoryMatch>
Notes
- <DirectoryMatch /path-to-django-projects/([^/]+)/media> is equivalent to writing <Directory /path-to-django-projects/site-name/media> for each site.
- mod_deflate configuration directives from the Apache site.
- Note that I'm sending far-future-expires headers for images and flash too — at this stage, that means I have to manually change the filenames whenever I change the content.
This means that for all my django sites' media directories:
- static content (except images) is gzipped via mod_deflate
- everything gets a header telling the browser to cache it for 10 years
Note you will need mod_deflate and mod_expires enabled in your apache config - if you have apache 2.2 it should just be a matter of copying the relevant files from apache2/mods-available/ to /apache2/mods-enabled/.
Minification
Step #4 was the trickiest of the lot, and many would argue that it's not really worth the trouble. Depending on how verbosely you comment your js and css, it may or may not be worthwhile for you — personally, I just thought I may as well go the whole hog. In the end, I probably only saved a few percent worth of bandwidth for my small content sites, but it'll be more significant with js-heavy web-apps.
For js minification, django-compress comes with jsmin built in. I've found this to be ideal for the job, and it is enabled by default.
For CSS, django-compress comes with CSSTidy — a CSS parser and optimiser — built in, in the form of csstidy_python. (You can also use a csstidy binary if you have one installed.) Personally, I find CSSTidy messes with my css, and more significantly, messes with that of my css framework of choice, 960.gs. I was after something that simply stripped whitespace, newlines and comments, without parsing the code. After scouring the web, I came across Slimmer — a lightweight pyhon app that did exactly what I needed. After installing it, I added the following file to the django-compress app's filters directory.
#compress/filters/slimmer_css/__init__.py import slimmer from compress.filter_base import FilterBase class SlimmerCSSFilter(FilterBase): def filter_css(self, css): return slimmer.css_slimmer(css)
Then it was simply a matter of adding the following line to my settings.py file, as per the django-compress documentation:
COMPRESS_CSS_FILTERS = ('compress.filters.slimmer_css.SlimmerCSSFilter',)
So my complete django-compress configuration in settings.py was as follows:
# compress app settings COMPRESS_CSS = { 'all': { 'source_filenames': ( 'css/lib/reset.css', 'css/lib/text.css', 'css/lib/960.css', 'css/style.css', ), 'output_filename': 'compress/c-?.css', 'extra_context': { 'media': 'screen,projection', }, }, # other CSS groups goes here } COMPRESS_JS = { 'all': { 'source_filenames': ('js/lib/jquery.js', 'js/behaviour.js',), 'output_filename': 'compress/j-?.js', }, } COMPRESS = True COMPRESS_VERSION = True COMPRESS_CSS_FILTERS = ('compress.filters.slimmer_css.SlimmerCSSFilter',)
Other best practices
I keep all my css within the <head> tags, and js at the bottom of the page — this is because the page browser needs to download all the css before it can start rendering the page, but doesn't need the js. It doesn't actually speed up the site, but it gives the impression of loading faster, and the user is unlikely to click on anything before the js has loaded anyway.
For a definitive guide, see Yahoo's performance rules. I also recommend Yahoo's YSlow, and if you are one of the 3 remaining web developers without it, Firebug.