Software Development Resources
Create account | Log in | Log in with OpenID | Help

Web application/Progressive loading

From DocForge

< Web application
Revision as of 16:16, 27 January 2010 by Matt (Talk | contribs)

(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Progressive loading is a design pattern for web applications which adds content to a web page incrementally.

The standard practice of generating an entire HTML document and then sending that to the web browser can be cumbersome for large data sets:

  • The generation of the entire document can be time consuming
  • Transmitting a large document to the browser, even when compressed, can appear as a bottleneck for users who are accustomed to quick responses from web servers.
  • Rendering a large document can be a performance bottleneck for a web browser. With increasingly complex pages, the largest bottleneck is often the web browser. Rendering a large table, for example, can be relatively slow.

Obviously if a data set is too large for a standard web page, it's probably larger than any user would want to see. The user experience is often improved by simply making the page smaller. For those rare times when a vary large web page is required, progressive loading attempts to solve these problems with specific steps:

  1. The web server generates and returns the "outer" page quickly, leaving out the large data set.
  2. Using AJAX, the web browser requests a subset of the data it still needs to display.
  3. The web server, at this initial request, gathers the complete or partial data set and returns the requested subset to the client. If the subset includes the last portion of data, it includes a flag to inform the client it has all of the data.
  4. The web browser renders the subset of data. If the included flag indicates there is more data to be displayed, it continues to make requests for more subsets of data until all are retrieved.

[edit] Benefits

There are multiple ways to improve the actual and perceived performance of web pages. Progressive loading offers its own benefits:

  • When all other bottlenecks are tackled, a large page can still respond slowly to the user simply due to its size.
  • The user can view data as more is retrieved.
  • The user can see immediate feedback that data is still loading (which supplements the web browser's own loading indicator with on-page feedback).
  • The user can select more data or interrupt the process.

[edit] Drawbacks

  • JavaScript is a requirement for this design pattern.
  • All of the content separately requested via AJAX will not be indexed by search engines.

The total amount of data transmitted to the user is effectively the same as sending the complete page, so this method does not reduce bandwidth.

[edit] Example

This example includes one web page, containing one table and a drop-down to let users filter the data. Here we use a combination of PHP and JavaScript with the mootools JS library.

There are a few risks and performance considerations with this particular implementation. For example, the JavaScript repeatedly requests data until it's told there is no more, or there is no response (e.g. from a server error). The risk is mitigated by letting the server decide how much data to return. The larger each data subset returned, the less requests will need to be made.

This is merely an example for demonstration purposes. Each implementation should be tailored to its specific scenario. Also, there are various ways the JavaScript can be written and organized; this example uses one style which is easy to read and follow.

The main web page contains an HTML/table empty of the data to display, only containing a visible "Loading..." and a hidden "No data" messages to start. A select element lets the user filter the data, reloading the table. Obviously the table and messages should be styled appropriately, but the CSS is not useful for this demonstration.

<html>
...
<form>
  <select id="product_id" name="product_id" onchange="javascript:load_products();">
    <option value="" selected="selected">All</option>
    <option value="1">First Product</option>
    <option value="2">Second Product</option>
  </select>
</form>
<table id="products">
    <thead>
        <tr>
            <th>Name</th>
            <th>Quantity</th>
            <th>Price</th>
        </tr>
    </thead>
    <tbody>
         <tr>
             <td class="loading" colspan="3"><div id="loading">Loading...</div></td>
         </tr>
         <tr>
              <td class="none-found" colspan="3"><div id="none-found">No games found</div> </td>
         </tr>
    </tbody>
</table>
...
</html>

The JavaScript initiates the data load as soon as the page is completely rendered. It continues to request more data until the server indicates it's complete. It also stops if nothing is returned, just in case there is a server error. This implementation uses the mootools JS library, but jQuery or another library could just as easily be used.

window.addEvent('domready', function() {
    load_products();
});

function load_products() {
    var table = $('products');
    var product_id = $('product_id');
    var product_id_value = product_id.getSelected()[0].getProperty('value');
    // Disable the product drop-down during table load.
    product_id.setProperty('disabled', true);

    // Show the "Loading..." message until we're done loading
    var loading = $('loading');
    var loading_row = loading.getParent().getParent();
    loading.setStyle('display', 'block');

    // Hide the "None found" message until we know we have no data
    var none_found = $('none-found');
    none_found.setStyle('display', 'none');

    // Clear out any old data by removing the data rows from the DOM
    $$('products tr.data').each( function(e) { e.destroy(); } );
    
    // Set odd / even classes on the data rows for styling
    var rowcount = 0;
    var lastrowcount = 0;
    function row_class() {
        if (rowcount % 2) {
            return 'even data';
        }
        else {
            return 'odd data';
        }
    }

    var done = false;

    function products_request() {
        new Request({
            'url': '/products',
            'method': 'post',
            'data': { 'product_id': product_id_value },
            onSuccess: function(response) {
                var data = JSON.decode(response);
                if (data) {
                    if (data.products) {
                        for (var i = 0; i<data.products.length; i++) {
                            var row = new Element('tr', { 'class': row_class() });
                            var td = new Element('td');
                            td.set('text', data.products[i].name);
                            td.inject(row);
                            td = new Element('td');
                            td.set('text', data.products[i].quantity);
                            td.inject(row);
                            td = new Element('td');
                            td.set('text', data.products[i].price);
                            td.inject(row);

                            row.inject(loading_row, 'before');
                            rowcount++;
                        }
                    }
                    else {
                        done = true;
                    }
                    if (data.last_set) {
                        done = true;
                    }
                }
                else {
                    done = true;
                }
                // If we didn't write any rows, we're done or have an error
                if (rowcount == lastrowcount) {
                    done = true;
                }
                else {
                    lastrowcount = rowcount;
                }
            
                if (done) {
                    // Hide the "Loading..." message
                    loading.setStyle('display', 'none');
                    // Allow the user to select another data filter
                    product_id.removeProperty('disabled');
                    if (rowcount == 0) {
                        // "No data" message
                        none_found.setStyle('display', 'block');
                    }
                }
                else {
                    // This is recursive, which is risky, 
                    // but the server decides when it's done, 
                    // or we simply stop if the last response was empty or broken
                    products_request();
                }
            }
        }).send();
    }
    
    products_request();
    
}

The PHP code responds at the /products URL. It calculates all of the data on first request, caches the results, and then returns the appropriate subset of data from cache on subsequent requests. If using a relational database, this method helps avoid multiple data queries over the same data set in a very short span of time. To get subsets of data, the database would have to repeatedly scan the same rows to determine which to return. If the query is complicate or expensive, caching the results to disk can be beneficial. Obviously this method should only be used in the appropriate situations.

Notice a few precautions on the server. The server decides how many rows to return. The server also keeps track of the offset of the previous request. This prevents anyone playing with the client code from manipulating the response much.

if (!isset($_SESSION['products_last_call']) 
  || ($_SESSION['products_last_call'] < (microtime(true) - 10))) {
    // Reset if the last run was interrupted or failed for some reason
    unset($_SESSION['products_cache']);
    unset($_SESSION['products_offset']);
}

// Use the cached data if available
if (isset($_SESSION['products_cache']) 
  && file_exists($_SESSION['products_cache'])) {
    $products = unserialize(file_get_contents($_SESSION['products_cache']));
}
else {
  // Populated the $products array with data row arrays
  ...
  // Then cache $products to disk, 
  // assuming it was expensive to compute
  $filename = tempnam(sys_get_temp_dir(), 'products');
  if (file_put_contents($filename, serialize($products))) {
      $_SESSION['products_cache'] = $filename;
  }
  else {
      error_log('Could not write products cache to file '.$filename);
  }
}

$length = 300;  // Let's return 300 rows at a time

// Where did we leave off last time?
if (isset($_SESSION['products_offset'])) {
  $offset = $_SESSION['products_offset'];
}
else {
  $offset = 0;
}

$subset = array_slice($products, $offset, $length);
$data = array('products' => $subset);
if (count($subset) < $length) {
  // The last batch was requested
  $data['last_set'] = 1;
  // Wipe the cache cause we are done
  unlink($_SESSION['products_cache']);
  unset($_SESSION['products_cache']);
  unset($_SESSION['products_offset']);
  unset($_SESSION['products_last_call']);
}
else {
  $data['last_set'] = 0;
  $_SESSION['products_offset'] = $offset + $length;
  $_SESSION['products_last_call'] = microtime(true);
}

echo json_encode($data);

Discuss