Animation of the multi state progressA problem that has always plagued web developers has been providing detailed progress indication for server-side tasks. The stateless nature of the HTTP protocol makes implementing a mechanism for constant, stateful progress information cumbersome. The main problem is that a given group of server side tasks will generally only result in one, aggregate response from the server.
In the ASP.NET community, several solutions have been offered. Some even provide an entire framework for monitoring the status of tasks in progress. However, they are convoluted and depend on constant server polling. I find polling for progress indication to be an inefficient approximation, when exact data is readily available.
With that in mind, I’d like to share an alternate method that I’ve used in the past with great success.
Note: While this example is written in ASP.NET and C#, the technique I’m going to describe is very easily portable to any language and platform.

Worse than watching paint dry

Suppose you had a long running group of tasks, such as these:
public void PrepareReport()
{   
  // Initialization.
  System.Threading.Thread.Sleep(10000);
 
  // Gather data.
  System.Threading.Thread.Sleep(6000);
 
  // Process data.
  System.Threading.Thread.Sleep(20000);
 
  // Clean up.
  System.Threading.Thread.Sleep(4000);
}
That’s 40 seconds we’re hoping the user will patiently wait on some sign of life from the server. Assuming that something went wrong, users are likely to give up and navigate away during something that runs this long.
Constant, granular progress feedback is exactly what’s needed to keep the user engaged while the tasks complete.

Asynchronously executing the tasks

The first thing we need to do is get away from the HTTP request/response cycle, by using some sort of asynchronous execution model. By doing that, the browser is still available to update the UI throughout the process execution. Rather than use formal AJAX, I chose to do something even easier.
Iframes: They’re underused, given the power and flexibility they provide, but not today. In this scenario, using an iframe is a great way to asynchronously execute the long running process, without being dependent on any particular AJAX framework and without tying up an XMLHttpRequest.
<input type="submit" value="Start Long Running Process" 
  id="trigger" onclick="BeginProcess(); return false;" />
<script type="text/javascript">
  function BeginProcess()
  {
    // Create an iframe.
    var iframe = document.createElement("iframe");
 
    // Point the iframe to the location of
    //  the long running process.
    iframe.src = "LongRunningProcess.aspx";
 
    // Make the iframe invisible.
    iframe.style.display = "none";
 
    // Add the iframe to the DOM.  The process
    //  will begin execution at this point.
    document.body.appendChild(iframe);
  }
</script>
When the button is clicked, an invisible iframe is dynamically generated and used to execute LongRunningProcess.aspx in the background. Next, let’s take a look at exactly what goes on when LongRunningProcess.aspx is executed.

Streaming update information during the process

In LongRunningProcess.aspx.cs’s Page_Load, I’m going to include the same set of tasks as before, but I’m also going to stream constant updates about what stage of the process is executing.
protected void Page_Load(object sender, EventArgs e)
{
  // Padding to circumvent IE's buffer*
  Response.Write(new string('*', 256));  
  Response.Flush();
 
  // Initialization
  UpdateProgress(0, "Initializing task.");
  System.Threading.Thread.Sleep(10000);
 
  // Gather data.
  UpdateProgress(25, "Gathering data.");
  System.Threading.Thread.Sleep(6000);
 
  // Process data.
  UpdateProgress(40, "Processing data.");
  System.Threading.Thread.Sleep(20000);
 
  // Clean up.
  UpdateProgress(90, "Cleaning up.");
  System.Threading.Thread.Sleep(4000);
 
  // Task completed.
  UpdateProgress(100, "Task completed!");
}
 
protected void UpdateProgress(int PercentComplete, string Message)
{
  // Write out the parent script callback.
  Response.Write(String.Format(
    "<script>parent.UpdateProgress({0}, '{1}');</script>", 
    PercentComplete, Message));
  // To be sure the response isn't buffered on the server.
  Response.Flush();
}
For each step of the process, this outputs a JavaScript callback to the page that opened the iframe. Using the combination of Response.Write and Response.Flush ensures that the callbacks are emitted in real-time, as the tasks are completed.
Since the relative run time of my example tasks are known in advance, I included completion percentages as well as the status messages. The raw output will look something like this:
<script>parent.UpdateProgress(0, 'Initializing task.');</script>
<script>parent.UpdateProgress(25, 'Gathering data.');</script>
<script>parent.UpdateProgress(40, 'Processing data.');</script>
<script>parent.UpdateProgress(90, 'Cleaning up.');</script>
<script>parent.UpdateProgress(100, 'Task completed!');</script>
*Note: Internet Explorer buffers the first 256 bytes of any response. To circumvent that behavior, it’s important that you push 256 characters down the response before anything else. Otherwise, the first few status updates will be buffered and the callbacks won’t be executed at the correct time (or at all, visibly) for users running IE.

Handling the callbacks and displaying status updates

The final step is to create a client side function to handle the callbacks coming from the iframe. To keep things simple, we’ll use the submit button’s text value to display the status updates.
function UpdateProgress(PercentComplete, Message)
{
  document.getElementById('trigger').value = 
    PercentComplete + '%: ' + Message;
}
Animation of the multi state progressNow, the submit button’s text will continuously update throughout the process, to reflect the current status of the process in real-time.

We’ve only just begun (and by we, I mean you)

I purposely kept the presentation very simple in this example, so that the underlying mechanism isn’t obscured by DHTML code.
However, if updating the button’s text isn’t visually stimulating enough for you, it’s easy to dress this technique up any way you want. For example, if you return percentages in your implementation, jsProgressBarHandler would be trivial to integrate with this technique and provides an excellent visualization.
You could do something similar to this, without an iframe, by using a sequence of page method or web service calls that had progress updates in between. The problem is that you’d have to move a lot of your logic into the presentation layer to do that. I would strongly advise against going down that road, when avoidable.
In real world use, you’re probably going to want to specify parameters to the long running process. Loading it in the Session is one way do to that. Even better, if you can manage it, is to provide arguments on the QueryString when setting the iframe’s src attribute. Then, you can check those parameters in LongRunningProcess.aspx.cs and respond accordingly.
As always, taking the source code for a spin is the best way to get a feel for it. Let me know what you think
.

Sample Example:

Default.aspx:


<%@ Page Language="C#" AutoEventWireup="true"  CodeFile="Default.aspx.cs" Inherits="sample_test.Default" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
  <title>Multi State Progress</title>
  <script type="text/javascript">
    function BeginProcess()
    {
      // Create an iframe.
      var iframe = document.createElement("iframe");
      
      // Point the iframe to the location of
      //  the long running process.
      iframe.src = "LongRunningProcess.aspx";
      
      // Make the iframe invisible.
      iframe.style.display = "none";
      
      // Add the iframe to the DOM.  The process
      //  will begin execution at this point.
      document.body.appendChild(iframe);
      
      // Disable the button and blur it.
      document.getElementById('trigger').disabled = true;
      document.getElementById('trigger').blur();
    }
      
    function UpdateProgress(PercentComplete, Message)
    {
      document.getElementById('trigger').value = PercentComplete + '%: ' + Message;
    }
  </script>
</head>
<body>
  <form id="form1" runat="server">
  <input type="submit" value="Start Long Running Process" 
    id="trigger" onclick="BeginProcess(); return false;"
    style="width: 185px;" />
  </form>
</body>
</html>


LongRunningProcess.aspx.cs:


using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

namespace sample_test
{
    public partial class LongRunningProcess : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            // Padding to circumvent IE's buffer.
            Response.Write(new string('*', 256));
            Response.Flush();

            // Initialization
            UpdateProgress(0, "Initializing task.");
            System.Threading.Thread.Sleep(2000);

            // Gather data.
            UpdateProgress(25, "Gathering data.");
            System.Threading.Thread.Sleep(1200);

            // Process data.
            UpdateProgress(40, "Processing data.");
            System.Threading.Thread.Sleep(4000);

            // Clean up.
            UpdateProgress(90, "Cleaning up.");
            System.Threading.Thread.Sleep(800);

            // All finished!
            UpdateProgress(100, "Task completed!");
        }

        protected void UpdateProgress(int PercentComplete, string Message)
        {
            // Write out the parent script callback.
            Response.Write(String.Format("<script type=\"text/javascript\">parent.UpdateProgress({0}, '{1}');</script>", PercentComplete, Message));
            // To be sure the response isn't buffered on the server.    
            Response.Flush();
        }
    }
}


Finally run Default.aspx.