Trap the Browser Refresh

Distinguish a button click from a browser refresh.

All clients who pay on time are great clients, and getting a call from them is a joy. Not all of their calls result in new gigs, however, and they don't always offer a technical challenge. Sometimes these calls simply mean boring questions and complaints. One such question I've been asked frequently sinceASP.NET 's beta 2 regards the unpleasant side effect that sometimes originates from hitting the Refresh button of the browser. When the user refreshes the current page, the ASP.NET runtime - under some circumstances - repeats the last action and reposts the data sent with the last round trip. As a result, the last server-side operation is repeated, which isn't necessarily a neutral event to the application. This also can violate - again, sometimes - the consistency of the application's data and trigger subtle errors and strange exceptions.

In this article, I'll try to clarify what really happens when the user clicks on the Refresh button and how this affects ASP.NET pages. I'll use a sample application to demonstrate that special measures are needed to discern a regular button click from a browser's refresh action. For a Web page, it's essential to know the call's reason so its code is executed safely and doesn't violate the application's integrity and consistency.

 

Refresh the ASP.NET Page

Normally, the browser stores recently visited pages in special folders on the user's hard disk: the temporary Internet files. This saves time when a user revisits a site because the pages are loaded from the local disk rather than downloaded again from the remote HTTP server. In contrast, when the user clicks on the browser's Refresh button, the page currently displayed is reloaded from the source - which bypasses any local cache. The refresh repeats the last HTTP verb, be it GET or POST. If the action was a POST, the dialog box in Figure 1 is displayed. If you choose to cancel the request, nothing happens; the page currently displayed remains displayed. Otherwise, the browser sends a POST from the HTML form of the page last invoked.


Figure 1. Take a look at the dialog box that Internet Explorer displays when you attempt to refresh a page. This dialog box is displayed only if the page was obtained through a POST command.

For an ASP.NET page, this behavior has a side effect. The contents of the input fields in the unique form are reposted to the server and processed as a postback. As a result, the last server-side action is repeated. Worse yet, there's no way for the ASP.NET code to detect whether it's been invoked - was it because the page was refreshed, or because of a deliberate and conscious user action? Nothing in the headers of the HTTP packet informs the Web server that the incoming request is actually a refresh request and not a command request. The ASP.NET runtime can detect, however, whether the page is requested for the first time or as the result of a postback event. The IsPostBack Boolean property on the Page class simply returns this type of information. Unfortunately, a similar mechanism isn't implemented to detect whether the page is being refreshed or requested.

Before I explore this aspect further, let's review a sample application that illustrates why the refresh action can be a serious problem for ASP.NET applications. This sample application contains a button that increases a counter by one whenever you click on it. The current value of the counter is displayed in the body of the page. But if you refresh the page after clicking on the button, the counter will be increased by one at each step. Figure 2 shows the source code of the sample page; Figure 3 shows the page in action and demonstrates the misbehavior.

<%@ Page Language="C#" %>

 



 

Click the button and Refresh


You clicked <% =Session["ClickCounter"].ToString() %>

Figure 2. This code shows a sample ASP.NET page that misbehaves guiltlessly when refreshed through the browser.


Figure 3. To reproduce the problem, click on the Click button to increase the counter by one and refresh the page. The browser asks for confirmation, then posts the input values back to the Web server. The page is processed and the last action is repeated. As a result, the counter is incremented again.

In the sample page the side effect is minimal - it's simply an incremented counter - and it doesn't jeopardize the application's stability. But the pattern behind it counts more than the effect. If users refresh an ASP.NET page, the last action could be repeated over and over again, seriously impacting the integrity of the application. For example, what if the last action consists of inserting a new record in a database? You could run into database-key violation errors or fill the database with cloned rows. If you're working with disconnected and cached data through a DataSet or DataTable object, the delete operation also is dangerous if you identify the row to delete by position. 

But what's the reason that induces ASP.NET to repeat the last operation in case of refresh? From the ASP.NET perspective, a refresh action is a postback - no more, no less. The HTTP request doesn't contain anything that indicates the origin of the packet and the ASP.NET runtime doesn't provide any built-in mechanism to detect the situation. When the user hits the Refresh button, the browser prepares a new POST request and stuffs in it the posted input values to obtain the current page (these values are cached internally). Typical values are the name of the Submit button that was clicked on to post the page and the contents of checkboxes and textboxes. For ASP.NET, the refresh request is like any other request and is processed as usual. If the previous call contained a postback event that, for instance, added a new record, it'll be executed again whether intentional or not.

 

Find a Workaround

When I first tackled this problem, I was fairly sure that a hidden field would've done the trick. My idea was to force the Submit button to write a flag into a custom hidden field when you clicked on it. If the page is refreshed - I thought - this code won't run, which gives the server page a way to discern between submit and refresh actions. Unfortunately, I was wrong. For this trick to work, in fact, the browser must reread the input fields upon refreshing the page. As you probably experienced on your own, this isn't what really happens. When users hit the Refresh button, the browser blindly repeats the last HTTP verb, which it has cached. In doing so, the flag written in the hidden field the last time the Submit button was clicked on is sent over again and thus cancels any difference for the server page between refresh and submit. Another approach is needed.

Given the browser's behavior, trapping the Refresh button click isn't a task you can accomplish on the client. When a page is refreshed, the ASP.NET page receives a block of posted values identical to that of a previous request. According to this pattern, if you assign a unique, progressive value - sort of a queue ticket - to each request in the session, you should be able to catch whether or not a request is new or a "rerun." If the ticket of the request being processed is older than the last ticket served, the particular request has been served already - hence, it's simply a page refresh.

Try this solution. To implement it, you need a couple of session-state slots - I named them Ticket and LastTicketServed - and initialize both to 0. The former contains the next ticket available for the session; the latter references the last ticket served within the session. Each page sent to the browser contains a unique ticket generated progressively from the current value of the Ticket slot. The ticket for the request is stored in a hidden field named  __TICKET. This code snippet shows you how to define it:

void TrackRefreshButton()

{

   int ticket = Convert.ToInt32(Session["Ticket"]) + 1;

   Session["Ticket"] = ticket;

   RegisterHiddenField("__TICKET", ticket.ToString());

}

Each new request for the page generates a new ticket greater than any previous ticket. The content of the __TICKET field is posted with other form fields when the page originates a postback event or is refreshed. (Note that hidden fields are treated as visible text fields and that a similar mechanism lies behind the management of the page's view state.) On the server, the ticket associated with the request is analyzed and compared with the last served ticket. If the request's ticket is greater than the last served, the request is processed for the first time - hence, a regular submit.

You need a layer of code running on top of the page to track the last served ticket and compare it against the request's ticket. This code also would be responsible for letting the page know whether the request is a submit or a refresh. Such a code block has some affinity to the code buried in the folds of the Page class and determines the page's postback mode.

One possible way to implement a component that traps the page refresh is through an HTTP module (see Figure 4).

using System;

using System.Web;

using System.Web.SessionState;

 

namespace AspNetPro

{

   public class RefreshTrapperModule : IHttpModule {

      public void Init(HttpApplication app)

      {

         // Register for pipeline events

  app.AcquireRequestState +=

               new EventHandler(OnAcquireRequestState);

      }

 

      public void Dispose()

      {

      }

 

      void OnAcquireRequestState(object sender, EventArgs e)

      {

         // Get access to the HTTP context

  HttpApplication app = (HttpApplication) sender;

  HttpContext ctx = app.Context;

 

         // Init the session slots for the page (Ticket)

         // and the module (LastTicketServed)

  if (ctx.Session["LastTicketServed"] == null)

      ctx.Session["LastTicketServed"] = 0;

   

  // Set the default result

  ctx.Items["IsRefresh"] = false;

 

  // Read the last ticket served and the ticket

         // of the current request

  object o1 = ctx.Session["LastTicketServed"];

         object o2 = ctx.Request["__Ticket"];

         int lastTicket = Convert.ToInt32(o1);

  int thisTicket = Convert.ToInt32(o2);

 

  // Compare tickets

  if (thisTicket > lastTicket ||

              (thisTicket==lastTicket && thisTicket==0))

  {

             ctx.Session["LastTicketServed"] = thisTicket;

             ctx.Items["IsRefresh"] = false;

  }

  else

      ctx.Items["IsRefresh"] = true;

  }

     }

}

Figure 4. Use this code for the HTTP module that checks the request ticket and determines whether the request is a regular submit or a page refresh.

The HTTP module intercepts the AcquireRequestState event and checks the session state associated with the request. The ASP.NET HTTP runtime fires the AcquireRequestState event immediately after the session state is bound to the request being processed. The HTTP module manages the LastTicketServed slot and - if the ticket is greater than 0 and greater than the last ticket served - sets it to the value of the request ticket. The comparison's outcome is translated in a Boolean value (true if it's a page refresh, false if it's a submit operation) and stored in the context of the request.

The HttpContext object represents the call context for the request and is common to all the modules and handlers that work over the request. The Items collection on the HttpContext object is a cargo collection where you can store shared data. Note that the HttpContext has the same lifetime as the request. The HTTP module sets the IsRefresh item in the cargo and the page can retrieve it later using the HttpContext.Current property (Figure 5 shows the page in action):

public bool IsRefresh;

IsRefresh = (bool) HttpContext.Current.Items["IsRefresh"];

 


Figure 5. The page uses the custom IsRefresh element in Context.Items to determine whether the request comes as a new form submission or through a page refresh.

You easily can integrate this article's downloadable sample code into a new page class, and it will hide much of the complexity of trapping redundant page refreshes.

The sample code in this article is available for download.

Hide comments

Comments

  • Allowed HTML tags: <em> <strong> <blockquote> <br> <p>

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
Publish