Source Unknown / Edited by Remi Woler

Content Delivery Network in PHP: Throttling download speed

closeThe world moved on since this content was published 7 years 1 month ago. Exercise diligent care when following any instructions and see opinions in the time they were written. If you must have an updated version, please ask kindly through the contact page.

During your daily browsing, you’ve probably stumbled upon those paid content delivery networks (CDN) a few times. Those networks give you a limited, low, speed if you are not a paying member, and give you full speed if you did pay to use the premium service. Though it can be annoying for users that want just one file, it can actually help you out quite nice to serve a lot of concurrent visitors, who want to download your latest release, while it’s still hot. If there would only be a simple way too limit speed… Well, there is! And it is pure PHP too.

The concept of limiting download is actually quite simple. Downloading a file is an example of a stream. And with streams, the output can never be faster then the input. Err wait, what did you say? Read it like this: everything that is in a stream has to be inputted first, before it can be outputted. So if you put it in slowly, it will be outputted slowly. Even with bursts, it can’t be outputted before it is inputted. With this in mind, it turns out that only 15 lines of code are needed to achieve this:

<?php
define(‘SPEED’, 51200);
$file=’100mb.bin’;

This chunk defines at which speed the file will be downloaded (in bytes per second), and which file will be downloaded. Of course, you will replace these with your own values in your script. Next up: the headers:

header("Content-Type: application/octet-stream");
header("Content-Length: ". filesize($file));
header("Content-Disposition: attachment; filename=".$file);
header("Content-Transfer-Encoding: binary\n");

With this headers, we set the Content-Type to ‘application/octet-stream’, which is the The Only Correct Way to force a browser to download a file. Other ‘hacks’ like ‘application/force-download’ work, because the browser simply doesn’t know what to do with it, so it proposes to download the file. This obviously might change in a newer version of your favorite browser, and will ruin your script. ‘application/octet-stream’ is documented in the RFC’s, so it won’t ever change, unless a new protocol is invented.

We set the Content-Length to the exact filesize in bytes of the requested file. We use a little ‘hack’ later on, to make sure we output the exact same amount of bytes. With the Content-Disposition header we suggest the browser that it should download the coming stream, instead of displaying it inline, and we suggest the same filename as the file currently has. Of course, you can change the filename to whatever you want. Finally, with the Content-Transfer-Encoding header, we announce the browser that the upcoming data will be sent in binary form.

So, now the browser knows what to do with the data that we are going to send it, so let’s move on to that part, shall we?

$fp = fopen($file, 'rb');

First up, we create a filepointer handle to said file. We want to read only, and because it is binary data, we set the access mode to ‘rb’. Now that we have an open connection to the file, let’s start reading in some data.

 while($chunk = fread($fp, SPEED))
{

Fread is our friend here. Fread will read the amount of SPEED bytes from file, and leave the filepointer there. If there is less data (remaining) in the file then SPEED bytes, then it will read up to EOF (End Of File). So this line is actually saying: For as long as there is data remaining in $fp (our filepointer), read up the amount of SPEED bytes, or to the end of the file, whichever comes first, and store that data in $chunk. This will create a chunk of data that will be sent to the browser in one second. Following so far? Then let’s move on. Not? Then replace the term SPEED with the number of bytes/second you want to stream to your user. We did define() it in our first piece of code, remember? Now read this last paragraph again.

 print $chunk;

And there is the part that does the actual content-disposition. It dumps the data that we just read to stdout, which is, in this case, the browser. Can’t be any more simple, now can it?

sleep(1);

And there is the magic of this whole script. This piece of code will tell the compiler to wait 1 second, and then move on. So, what just happened? We outputted the amount of SPEED bytes to the browser, and we waited one second. Combine that in one line, and we have SPEED bytes per second. Exactly what we need! Now just loop through the whole file, and we are done!

 unset($chunk);
}
fclose($fp);

Though it is unnecessary, you can unset your $chunk if you want. That way, you are sure it is refilled with new data, or the loop will be exited. PHP should always re-define $chunk on (re-)entering the loop, but better be safe then sorry :). After that, we close the loop, and close the file handler. We’ve outputted everything, so we don’t need the handle any longer. And now for the little hack I promised earlier:

 exit();

That’s all. There’s your hack. And no, you can’t leave it out. Here’s why: Everything you output after the headers, will be stored in the new, downloaded, file. And PHP will append a new-line to the script after it’s compiled and processed. And we don’t want to have that new-line in our file, right? So we exit the compiler prematurely.

And does it actually work? Yes, it does. Look at the following screenshot:

As you can see, the speed is limited to 50KB/s here. Keep in mind that the advertised speed will not always be the same as the set speed. Especially in the beginning, you will see higher values, and yet, the script still works.

Why does it advertise a higher speed? This can be two things, which affect each other. While you still see the download dialog of your browser, we are already feeding data to the browser. The browser will store this data in a memory buffer. After you have selected the place to store the file, and instructed the browser to store the file, this data from the buffer will be copied into the newly created file. This happens at a way higher speed then your regular download speed. The second option is that your browser displays the average speed. If you peak high at the start, and then continue at the defined speed for the same amount of time, the average speed will be the meridian of both speeds. If you wait long enough, the advertised average speed will be the same as the defined speed.

Why would it advertise a lower speed? This is a much more complicated question. Most likely, it will be that either the client can’t cope with the speed (even if you set the speed to 100Megabyte/second, a dial-up client won’t download any faster then 56Kilobit/second), or the server is serving too many concurrent clients, and doesn’t have any more bandwith left to reach the defined speed.

Can I see the script once more, but now complete? Sure you can, here you go:

<?php
//define speed and file to use
define('SPEED', 51200);
$file = "100mb.bin";
//send headers to force the browser to download the file
header("Content-Type: application/octet-stream");
header("Content-Length: ". filesize($file);
header("Content-Disposition: attachment; filename=".$file);
header("Content-Transfer-Encoding: binary\n");//open file pointer for the file
$fp = fopen($file, 'rb');
 
//loop while there is data remaining in $file
while($chunk = fread($fp, SPEED))
{
//output data, and sleep for 1 second
print $chunk;
sleep(1);
unset($chunk);
}
//after everything is said and done, one and one still is one, err, we clean up
fclose($fp);
//and we leave the premisis prematurely, to avoid the new-line
exit();
?>

~RW

Filed Under: Tutorial

Tags: , , , , , ,

Released: on Jul 20, 2007 under a Creative Commons Attribution-NoDerivs (CC-BY-ND) licenseCC-BY-ND

Comments (4)

Trackback URL | Comments RSS Feed

  1. click-stop says:

    with your example, couldn’t a user enter the file address directly in the browser?
    $file = “100mb.bin”;
    http://www.yoursite.com/100mb.bin
    and hit enter? no throttle?
    wtf am i missing.

    • Remi_Woler says:

      That depends on where you place the files. The idea is to place the real files outside of your webroot, so the only access to them is available through the script. With a bit of mod_rewrite magic, you can even make it look like the person is actually downloading the real file, and the script will still kick in.
      There is a follow-up coming on this article, with for example the mentioned .htaccess, but I got a little tied up in moving to my new house, so that might take a while before it is posted.

      Please feel free to ask any remaining questions.

      ~RW

  2. Geoff Eagles says:

    Works fine ONLY when the file being transferred is on the local server. fread(), when connected to a stream, only reads until a packet becomes available (it pays no attention to the value of SPEED) so, your delays won’t work as intended.

  3. owen clivet says:

    thanks for the code dudes