Serving Images from Database with Browser Caching.


In my news-feed web app, the page is generated by a PHP script. It can include numerous images that are drawn from a database. I had been struggling to get the browser to cache these images so the page would load quickly, to no avail. Nothing I tried seemed to have any effect. I would still see larger images appearing band by band as the images were loading thanks to my slow Internet connection.

But then I discovered Smirnoff - well, not really, but I discovered this page. It is a little terse, so I thought I would explain my resulting PHP script. Now I'm a happy man - the page loads like lightening (well by my standards), and after the first time, the images are just there.

Changes - step 1, no query string.

One of the things I had tried first, and have retained, is the elimination of a query string. Originally I would give a src attribute for the images of the form I thought this might make some browsers reluctant to cache the image, so now the URLs are of the form The '1234' here is supplementary path information. The PHP handles this with:

$iid = substr($_SERVER["PATH_INFO"], 1);
The substr is there to remove the leading slash that you'll find on $_SERVER["PATH_INFO"].

Changes - step 2, timestamp your images.

My images database table already had an automatically populated timestamp column, so I did not have anything to do there. If you haven't you'll have to add one and populate it with times in suitably distant past.

Changes - step 3, get your head around HTTP_IF_MODIFIED_SINCE.

The thing that causes the browser to send an HTTP_IF_MODIFIED_SINCE header is, somewhat counter-intuitively, an incoming header that tells it it must revalidate the URL it is requesting. That's what I had been missing all the time ;=( There are other headers:

// Like I said
header("Cache-Control: must-revalidate");
// Like the recipe says
header("Last-Modified: ".gmdate("D, d M Y H:i:s", $mtime)." GMT");
// Force images to be downloaded again if your PHP script changes 
header('Etag: '.$etag);
// Persuade stubborn browsers to revalidate
header("Expires: -1");
These get sent the first time the browser requests your images. On subsequent requests it should then send the magic header, which we can compare with the timestamp from the database (this must be in Unix Time format). Something like:

       && strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) >= $mtime)
   header('HTTP/1.0 304 Not Modified');
There is a little more to do to make the browser start from scratch if you modify your PHP image server script, but you'll see that in the final version:

include "database-connection-stuff.php";

// Get the image ID
$iid = substr($_SERVER["PATH_INFO"], 1);

$handle = dbConnect();
if (!$handle) die("Database connect error");

// We want the date of the image from the database to be in Unix Time form
$query = "SELECT type, UNIX_TIMESTAMP(ts), imgdata from images where iid=$iid";
$result = mysql_query($query);

// If it's a spurious iid, we want the image to be empty
if (!$result || mysql_num_rows($result) != 1)
   header("HTTP/1.0 404 Not Found");

$row = mysql_fetch_row($result);
$itype = $row[0];
$itype = ($itype == 1)? "gif": (($itype == 2)? "jpeg": "png");
$mtime = $row[1];

// Make an etag that will change if we modify this script
$fp = fopen($_SERVER["SCRIPT_FILENAME"], "r"); 
$etag = md5(serialize(fstat($fp))); 

// Send the required headers
header("Cache-Control: must-revalidate");
// This one tells it what time to ask for
header("Last-Modified: ".gmdate("D, d M Y H:i:s", $mtime)." GMT"); 
// In case this script is changed
header('Etag: '.$etag);
header("Expires: -1");

// Then the magic stuff - the '@' on strtotime() will suppress any error message if the browser has not
// sent the header.
if ((@strtotime($_SERVER['HTTP_IF_MODIFIED_SINCE']) == $mtime) && ( 
    trim($_SERVER['HTTP_IF_NONE_MATCH']) == $etag)) { 
    header("HTTP/1.1 304 Not Modified");

// Last but not least, tell the browser the image type and send the image data
header("Content-type: image/$itype");
echo $row[2];
Bingo! My page is much more nippy, and I don't eat though my paid-for bandwidth at the same rate ;=)