Scroll down to the script below, click on any sentence (including terminal blocks!) to jump to that spot in the video!
SymfonyCon 2019 Amsterdam presentation by Matthias Pigulla.
HTTP caching is a powerful technique to improve response times for site visitors, make more efficient use of bandwidth and reduce server load. We will have a look at the basic concepts, the different caching strategies and HTTP headers used to implement them. I will then show you a few ways how to implement HTTP level caching in your Symfony application, how to leverage the HTTP Cache included in the framework and resolve the mystery around ESI.
My hope is that when you return to work on monday, you will bring some new tools to your project that you can start using right away.
So welcome everybody. I hope you have been enjoying the first conference day so far. And um, I hope you all brought a bowl of popcorn for this talk. This is, um, the HTTP caching fundamentals talk and I've designed this for the beginners track. So my assumption will be that you know some the basics of Symfony and have used it for some time, but you need not bring any thing, any knowledge regarding HTTP caching in particular. I'm going to cover all the basics. Regarding questions, I might be a bit short on time, so I hope you don't mind if you could just keep note of your questions and we'll postpone them and have a Q&A section in the end or you just come up in the, in the, in the lobby and ask me after talk.
So I would like to start with a maybe simple math question for you. Just check the answer. How much is 34 times 17? 578. Awesome. Great. Okay. Um, sorry, let me try this again. How much is 34 times 17? Great. Okay. And now that was way faster. And what you've just seen in the front here is caching. So you've done some compute intensive work, hot labor and we're able to put the answer on shelf and reuse it just in case someone else comes around later and asks the same question again. So if I'd speak HTTP, I might have asked you something like, like this: a GET request, GET /multiply, whatever. And um, if you were caches you would have built what is called the effective URL. So, you would basically put all this information together into one URL that contains everything, assuming we're talking over an encrypted connection, you would also put this HTTPS in there. And with this effective URL you would build a cache key.
So, the cache key is basically for a key-value store where you have a key to put stuff and get it back later on. And in this cache key you put the HTTP method that you've been, that we've been using - this is GET in this case - you put in the effective URL that we've just built and there may be some other stuff that also goes in there, but for now we can just ignore that. And then you wait for the response to come along and, oh, not, sorry, not wait for the response, but you would only do this for some kinds of requests.
For GET obviously and for HEAD requests and um, the standards even allow you to do this for POST requests. Now this is something you might keep in mind for the social event tonight as a fun talking point. But, um, you should really try to post, um, to cache POST requests in production and there are lots of quirks and must do and edge cases where you may or not do this.
So, almost any cache will anyway just ignore the POST requests, but it's in there in the spec, but just as a fun fact. So then you'd wait for the answer to come along.
And um, yeah, there, there are quite some status codes that come back that you might want to put into your cache. Obviously 200 is successful response as the first one you want to cache, but it's also perfectly fine to cache other things like the 301 "Moved permanently" or the "Not found" 404 status code because that's all information that might still be valid quite a few minutes or sometime later. This is a quite a long list of status codes that permit caching. And in fact those even permit caching by default, that is without any further tweaks or configuration and had a sent these status codes are allowed to be put in a cache.
Now I've sketched up a little, a picture of the internet. So what you can see, um, on this side, on the right hand for you, it's the left hand side, sorry. Um, is what the standards called the user agents. These are the clients that issue requests to your service. And on the other side we have what is called the "origin". So the origin is the server that knows the ultimate answers to those requests. And in between those two, there may be some caches, some even some layers of caches. Um, yeah, you maybe have heard of CDNs, content delivery networks, which are basically just HTTP caches that try to be as close as possible to the, um, to the user agents to answer requests as fast as possible. Or you may have a, well, reverse proxy. Sometimes also it's called a gateway cache, a load balancer in front of your application service in case you're using several of them.
All these, um, 3 on the right hand side of together called the surrogate caches because those are caches that are typically under your control. You can configure them, you choose to deploy them and they are acting as um, on your behalf. So you control those. Now I've put these, um, green and bluish dots inside the components to show where the actual, the actual caching takes place. We may have a cache in the browser, which is called a private cache and it means that this cash keeps information only for one particular user agent for one, maybe person. Whereas the other caches with these bluish dots are called the public or the shared caches because they star requests and responses for more than one user agent. So that's the one distinction I would like you to take away from this slide. And between those components we will be speaking HTTP.
So I guess the browser is not using HTTP to communicate with its own internal cache, but, um, when we move across process boundaries or host boundaries, then HTTP will be the way of talking to each other. So if you want to read more on this, um, this is, um, these other standards, the place where you can look it up.
And that brings us to the part of the presentation I'm a bit afraid of because, um, now we see what happens. So what I have prepared for you is a simple Symfony sandbox application, that I will use to demonstrate caching. I will be giving you the address of the GitHub repo later on so you can use it and play around yourself. What I would like to demonstrate you first is that... Is this readable from from all the way back. Yep. Okay. Thank you. So the first thing I would like to show you is um, I have a simple view that just shows the headers sent by the user agent that are relevant to caching when I request this page.
Okay. So, um, in this case... Also this is readable that I like this. Um, in this case nothing particular has happened. And also when I follow a link, which in turn takes me to the same page, nothing happens. If, however I entered the address in the address bar, you'll see that now there's another header, which happens to be the same when I press the reload button. And yet another one is submitted when I use a force reload that is using shift and pressing reload. So what I would like to point your attention to is that when trying to understand or debug why caching works or does not work the way you expect it to be careful when doing this from your browser because there's always some state and a browser on which page you have been before and which is the page you're currently at and how are you trying to reload it.
And it may be hard to, to really see what's going on if, if you're using this. So instead it would be better if, um, if you add another tool to your toolbar and I'm sure most of you have heard about this, this is called cURL. cURL is the command line tool for command line tool for, yeah, just fetching content over HTTP and doing lots of stuff. And um, for example, I could just make a simple cURL that's a capital -I - it says I want to make a request and please just show me the response headers. I add an "-X GET", which tell cURL that I want it to make a GET request because when I use this ice which usually just makes a HEAD but I want it to be a GET and then I have to add the address, which is this one.
So, and and what I get back is the, um, basically the um, the HTTP response and the cache control headers. So this is more reliable when trying to understand what's going on because there's also no cache built into cURL. So you get clean and predictable responses every time.
Sorry, wrong URL, thank you. I'm going to rely on you the next 40 minutes. Thank you. Okay. Um, so now we have a cache control max-age=10, max-age=10, um, tells the user agent that is okay to keep this response and the cache for a lifetime of 10 seconds and you need not come back. Ask me about this for the next 10 seconds. So this is basically the, the fastest response you can get is one you don't even have to ask for because you know the answer already. So, if we try this in the browser, and I'm going to reload this, I don't know what's wrong about my DNS. So I've just fetched this and now I'm going to click this link again. Click it again, click it again. And you can see that only the time in the browser advances so this page is rendered again, but it has not been fetched from the server, from scratch, which will happen right now because now this 10 second period has expired.
So, once this response has exceeded this 10 second lifetime, um, it is called to be "stale". So, unless the time is, um, past due, it's fresh. And after that it transitions to a stale state. Being stale, however, does not mean that the browser or the user agent has to just discard everything. But it merely means that it has to revalidate. That has, it has to check back with the server and fetch, no ask again if it's still okay to still use the same response. So to show you how this validation thing works, I'm going to add another.
I'm going to add another method. This, this view just shows the current week day. We also need a round here. Okay. That works. And um, now I'm going to add two additional headers to this response, which are called the ETag. I'll just put in some ETag there, explain on that in a minute, and I'll also say that, um, this thing has been last changed or was it today midnight? Yeah, the day changed at the midnight, but I'm not sure if there's a today midnight expression is correct. Let's find out. Yes, it is. So now the response that I'm sending includes those two additional headers, ETag... No, that one and that one.
So, the ETag is basically some identifier that is sent by the server to the client. It does not have any meaning to the client. So it's just an OPAC string and we have this last modified information. And using this information, a client can now check back with the server and ask whether it's still okay to use this response. Can ask has anything changed on the server since I have last seen this ETag or last modified information from you. So, with the ETag, what happens is that the user agent includes an additional request header, which is the capital H switch adds an additional header to send to the server and it says if none match - there's so um, it asks the server to send me this response, but only if none of these attacks that I've given you actually match.
And what happens is that I get back at 304 "Not modified" response. This is the server's way of telling me that nothing has changed since I have seen this attack. And it's okay to just keep on using the response that I already have. We can also do this with the um, the last modified information, in which case it's not the if not match header but it's if modified since, just send me the response if it has been modified since that point in time.
And again it's a 304, it has not been modified on the server since then. Now you might wonder where this 304 actually comes from because there's nothing special in my controller that I've done to just verify this information. And in fact this is because I'm using Apache as my web server and the web server was smart enough to just get the regular 200 status code response from Symfony, see that there is a matching ETag or last modified information and then it just omitted sending back the response buddy and instead turned it into a 304 "Not modified". So, in Symfony we've gone all the way through this and did all the work of rendering the... the response and running Twig and stuff. So, we do better in this case if we, if we take the risk, the requests, and then we have to create the response.
Sorry, we have to create a response ourselves and then we can use a method. If response is modified, is not modified - request. So, this particular method compares the request that has been made to the response that we are probably going to send out. And if this has not been modified, we can return the response right away in this place. And only in case we see it that there has been a modification, we need to do the actual work of rendering the Twig template. So, in this case we can render it and put it into the response object that we already have. And let's first see that it works at all. It does. So now if I add this, if modified since header, sorry, I want you to be able to read this. Um, if I add this "if modified" since header again, we now get this 304 status code like before. But in this case we've been able to shortcut all the work inside the controller and we did not need to, yeah, render the template and stuff. Just to prove you that this really works I'm going to, I'm going to throw a runtime exception right here. So, sorry.
This one's the worst because it's the one that shortcuts the control action. But if I omit this header and ask for the complete response, we're now getting this internal stop error just because there's the, um, the runtime exception in here. So, what you've now seen is basically a combination of um, yeah, the validation and um, now there another header that we haven't... oh, it's not a header, it's a, a, a, a directive inside the cache control header, which says must revalidate., Must revalidate, tells the user agent that once this response that it has has turned stale, it must, in any case, check back with the server to make sure that it's still up to date. So you might now wonder on the which conditions this response can be stale at all because we have not specified a lifetime for this response at all. And in fact, user agents are allowed to... when, when the cache control headers present and says, it's okay to keep this and your private cache, then user agents are allowed to calculate a heuristic expirations. So based on this information in the last modified header, which has now I'm about 16 hours ago, the um, user agent may figure out that it is probably safe to just cache this for a lifetime of let's say 5 minutes or so. So if you want to prevent this, heuristic, um, expirations, it will be better to just be explicit about the exploration you would like to use. And we could either add a flag to this response and say "no cache".
No, we have no cache private again, which is also the default issued by Symfony, which in fact does not say it's not okay to put it in the cache. So this does not prevent caching, but it just says: if you cache this, you must not serve it from the cash without asking me. So, no cache is probably one of the big, yeah, I would say I'm a misnomer and this not really intention revealing what is some, what this header does. So yes, keep it in the cache, but um, asked me every time. So this will be one way to prevent this heuristic caching from taking place or the other one would be, um, just as before to add the, um, this max-age thing.
And I'm going to do this now with a, with the cache annotation from the FrameworkExtraBundle, I can now say, okay, keep this for 10 seconds and now we have a response that can be kept in a cache for 10 seconds and after those 10 seconds have expired, the response becomes stale and we need to perform revalidation by either checking with the ETag which is the preferred way of doing this or by using the last modified information. And if we find out that their response is that, well we can use it for another 10 seconds.
So this is all fine and dandy and we have now responses then that can be kept in our user agents caches. But we have not really achieved any performance improvements on our server side. So we're still basically facing the same requests and um, have to do all the same computational work. And um, to improve on on this side, we need to add another component. We have to add a public or a shared cache to the Symfony application. Now what I'm not sure if you know about this, there is a HTTP cache implementation written in PHP, which is part of the Symfony distribution as you get it. So you do not need to start with Varnish and have complex server setups, but you can just use the Symfony HTTP cache and basically if you can deploy PHP to a server, you can include the HTTP cache as well. And, um, it's pretty much stable and it offers a very fast response times already. So that may be a good thing to start with. So let me show you how this works by adding it to our demo application.
To do so we need to go to the index.php file, which is the front controller, the very first script that is started when, when Symfony actually starts. And we put this cache right here and wrap it around our Kernel. So you may have heard of the decorator pattern and object oriented software, which means that you put another object, you put a new object in front of another one to intercept, handle and modify the requests that are being made.
So, I'll take this one, put it in front of the Kernel and fired the same request again. Now, either I forgot to remove. No, I didn't. You know what's wrong? Nope. Okay. Oh I, I probably chose the wrong cache implementation. Let me try this again. That's the one I want to be using. Okay.
So, I make the same request again and we can now see that there is an additional header that has been added. So the Symfony cache is now in front of the Symfony kernel and all requests that usually were directed to the kernel and now first handled by the Symfony cache. And, um, yeah, the cache can either try to provide a response as fast as possible and return it without even starting the Symfony kernel booting it up at all or otherwise, if it has not yet cached a response, it will need to just do what, what happens to usually. So in this case, um, this, this information is added in, in the debug mode only and it tells me while the cache is in place and I've seen a GET request for a /weekday and um, still valid, forget about this. That's from my tests. I need to just remove what's in the cache. Let me do this again. So this is what you should expect. Um, I've seen the request for /weekday, but that was a miss. I do not have anything in cache for this. And this is because the method that I'm using here is still using this private header. It says you may, you must only keep this in a private cache, but you must not share this information between several users. So in order to make our weekday cachable and the shared cache, we go to this
showWeekday() controller. And um, I just use the annotation. I'll say this is a public response. It's okay to use this in a shared cache as well.
No, look at this line. The request was a miss. There was nothing in the cache, but we are now able to store the response and what we see is a response that has been so from the cache. So cache requires to add this Age header. The Age header tells any downstream clients how long this response has been sitting in the cache altogether. So it's important if we know that we may use this for a period of 10 seconds, it may be important to know that it has that it has been sitting for 5 seconds in a cache already because that reduces the time that we are ultimately allowed to make use of this response. So that was a miss, but it has been started. Now I'm going to make this request again. Now the cache says: okay, I had a stale response because the 10 second period had expired.
I validated that with the backend server and I figured out it's a valid. And so I started and kept it and um, yeah, by the way, here is the response and I got this as a 200 status code. Now I'm going to repeat this two more times and be a bit quicker. Now this time I was so quick that um, I even got a fresh response response from the cache. So the cache did not even have to contact the Symfony kernel at all. We did not even boot the Symfony kernel, but we could just grab the response from disk and um, crank it out. So, um, yeah, that's the best possible case that can happen to you.
Now I need to, um, put a, what a word of caution, um, regarding public caching because if you put this public cache control header on your response, it basically says that it's okay for any cache along the way to keep this response and this even overrides some protection mechanisms that are in HTTP by default.
So, for example, if you're using HTTP authentication and this provides the authorization header and that usually prevents any cache from storing the response. But if you put this public in there, it says it's perfectly fine to keep this and don't worry about the authorization thing. Also, when it comes to cookies, session cookies, you may have some lock in area on your pages. This information is not taking into account when the cache keeps a public response. So be careful when adding public because that can lead to, yeah, just mixing up content that is for different users. And um, you may not even be aware that some user is getting someone else's response because you usually don't see what is going on in the caches downstream. So did I say you should be careful with adding public to your responses.
Well if you... if you do this, there's one more header. I'm not going to demo this in detail, but I want you to tell about this. This is called the Vary header, which you can set on your response. And with this Vary header, you give the caches information, um, that they need to put additional information into the cache key I mentioned in the beginning. So when I said we skip this information for now you can tell a cache that it can cache a response, but it also needs to put, for example, the cookie header into that cache key. So you can make sure that different users with different cookies get different responses, which if you make this this way for four sessions, basically you can omit the cash because it will be caching one, one thing per one user. But there may be cases where we are able to yet just find a set of headers that makes a perfect combination. So you pick different responses apart and at the same time still be able to use them for, for similar users.
So, if these are the two options, what, what can we do to have requests in our cache, um, where we maybe want to mix private and public things. So for example, a part of the response is meant to be for one particular, particular user only. Whereas the other part is public. Or maybe we want to have responses where some information is only valid for a very short time and other parts of the information can be used for much longer.
And for this use case, there is a cool feature that is also built in Symfony which is called the Edge Side Includes (ESI). Edge Side Includes is originally a technique to get just compose a page of several fragments and to do this as close as possible to the user agents at the edge of the internet or on the edge side caches. But you can also use the Symfony HTTP cache to do this just in front of your back end. And um, I would like to show you how this works by creating a dashboard, lets say dashboard action. So, I want to issue a greeting to some user, which is obviously a private response because it contains the person's name. And I also want to include the weekday and the time as we've seen before and put this all together. So I will write this showDashboard(), response. And I've also prepared a Twig view for this one. We need to add a route again. Now in this dashboard, nothing fancy so far. Let's try this. Yeah, it just fetches the name parameter from the request and hopefully says "Hello SymfonyCon". So nothing fancy so far, but now we're going to just embed what we've, what we've seen before the time and the weekday and we do so by using a Twig helper function called render_esi(), which renders and edge side include. And inside that we need to put a reference to the controller that we would like to be executed in displays. So I'll just go back to this demo controller, copy a reference to this method. Put this in here and I need to recycle the back slashes. Put them in here over there. And I'll do this for the... Sorry?
I'll do this for the weekday as well. Now this is a feature I need to turn on in the framework configuration. So we go to the framework Yaml configuration. We have those two switches, ESI, which you may have noticed before. And also the fragment thing. I'll just turn both on for now and show you what happens. And then the best case, we now have a response that contains both the name and the information that we've had before. You may be surprised that um, those links are missing in the fragments. That's because I am in my, in the base template. I've made sure that, um, I only add the HTML body and stuff once for the entire response. And when I detect that we are adding fragments together, then I just skipped this. So just to yeah, I probably confused you with that.
Now what we see is that, um, we get this entire response, but I would like to show you how this works behind the scenes and um, to do so I just pretend for a moment that I am and yes, I aware of cache and I, um, I need to first turn off the cache, the front controller for a second because if the cache is present, it's going to do the ESI processing for me. And I cannot pretend to do this myself. So just comment this out for a second and then we will be doing this. This request, do I need to quote this? I'm never sure. And then I will tell the cache, no, the HTTP kernel fact, that I am a Edge Site Include aware cache in front of it. So, you need to know about this one. Um, that's just a, that's just for showing what happens behind the scenes. So this tells the Kernel, um, I'm a surrogate cache in front of you and I'm capable of processing Edge Side Includes. Okay. So, this time I would like to see the response body in fact, and what you can see is that now this dashboard request that I've, that I've made includes this fancy ESI include markup. So this is the first response that is returned by the kernel and put into the cache. And now the ESI processor inside the cache will have to fetch two additional resources, which are referred by this, this, this mockup. And um, yeah, ir will just fetch that, put it in this place and let's again turn this on.
Go back to the usual request. And, um, we can now see that the Symfony cache has processed a whole lot of requests. The first one is the main request, which was a miss. The second one was this fragment with this particularly long URL, which was also a miss. And the third one, well it was just stale and could be validated and could even be start later on. So what the cash has been doing now is that it's basically just juggling with three different responses. It has a cache, each of those with its own cache control information. And whenever a request now hits the cache, the cache will take those responses from the, from, from storage, merge it together and find the best possible combination of know of cache control headers that still satisfy everything that's gone into this. So, because my, uh, my personal greeting page is private and no cash. Obviously the anti response must also be private and no cash. But, um, we can make use of the, did I leave this cacheable?
Well, the, the, the showtime controller is not even public. So, um, we need to make this public as well. And now we have the time that it can be cached for 10 seconds. Um, we have the weekday that can be cached for 10 seconds and now we should be able to, um, repeat this request a few times and now the caches should be warm and um, we have a fresh, so a completely cache-based HEAD for, for both of the fragments but still a miss because we do not store the, the personal information at all and we can build this all together and this can give you pretty good performance boost if you are able to just separate the public and the private content generated by your, by your application.
Yeah. I promise to give you the um, the uh, GitHub link. So this is the address of the repository where I've put this. Um, yeah, I also try to put, um, the changes I've made during this presentation so you can follow up on that later on and maybe you can use it to just play around and yeah, get a feeling of how things work.
So, if we have time for questions, which I'm not sure when, when shall we finish at? Yeah, quarter past four. Sorry, one minute. Yeah. Okay. So a one minute is a bit tight maybe. So if you want just, just come up after this talk, ask me your questions or ask me at the social events tonight or tomorrow - I will be around for the entire day. Um, yeah, and just, um, see if I can provide any useful answers. Otherwise, have a great conference. Have a great social events tonight, enjoy your stay and thank you for your time.