Trap the Browser Refresh

Distinguish a button click from a browser refresh.

Dino Esposito

October 30, 2009

10 Min Read
ITPro Today logo

All clients who pay on time are great clients, and gettinga 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 simplymean boring questions and complaints. One such question I've been askedfrequently sinceASP.NET's beta 2 regards the unpleasant side effect that sometimesoriginates from hitting the Refresh button of the browser. When the userrefreshes the current page, the ASP.NET runtime - under some circumstances -repeats the last action and reposts the data sent with the last round trip. Asa result, the last server-side operation is repeated, which isn't necessarily aneutral event to the application. This also can violate - again, sometimes -the consistency of the application's data and trigger subtle errors and strangeexceptions.

In this article, I'll try to clarify what really happenswhen 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 neededto discern a regular button click from a browser's refresh action. For a Webpage, it's essential to know the call's reason so its code is executed safelyand doesn't violate the application's integrity and consistency.

 

Refresh the ASP.NET Page

Normally, the browser stores recently visited pages inspecial folders on the user's hard disk: the temporary Internet files. Thissaves time when a user revisits a site because the pages are loaded from thelocal disk rather than downloaded again from the remote HTTP server. Incontrast, when the user clicks on the browser's Refresh button, the pagecurrently displayed is reloaded from the source - which bypasses any localcache. The refresh repeats the last HTTP verb, be it GET or POST. If the actionwas a POST, the dialog box in Figure 1 is displayed. If you choose to cancel therequest, nothing happens; the page currently displayed remains displayed.Otherwise, the browser sends a POST from the HTML form of the page lastinvoked.


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

For an ASP.NET page, this behavior has a side effect. Thecontents of the input fields in the unique form are reposted to the server andprocessed 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 beeninvoked - was it because the page was refreshed, or because of a deliberate andconscious user action? Nothing in the headers of the HTTP packet informs theWeb server that the incoming request is actually a refresh request and not acommand request. The ASP.NET runtime can detect, however, whether the page isrequested for the first time or as the result of a postback event. TheIsPostBack Boolean property on the Page class simply returns this type ofinformation. Unfortunately, a similar mechanism isn't implemented to detectwhether the page is being refreshed or requested.

Before I explore this aspect further, let's review a sampleapplication that illustrates why the refresh action can be a serious problemfor ASP.NET applications. This sample application contains a button thatincreases a counter by one whenever you click on it. The current value of thecounter is displayed in the body of the page. But if you refresh the page afterclicking 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 inaction 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 thatmisbehaves guiltlessly when refreshed through the browser.


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

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

But what's the reason that induces ASP.NET to repeat thelast operation in case of refresh? From the ASP.NET perspective, a refreshaction is a postback - no more, no less. The HTTP request doesn't containanything that indicates the origin of the packet and the ASP.NET runtimedoesn't provide any built-in mechanism to detect the situation. When the userhits the Refresh button, the browser prepares a new POST request and stuffs init the posted input values to obtain the current page (these values are cachedinternally). Typical values are the name of the Submit button that was clickedon 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 theprevious call contained a postback event that, for instance, added a newrecord, it'll be executed again whether intentional or not.

 

Find a Workaround

When I first tackled this problem, I was fairly sure thata hidden field would've done the trick. My idea was to force the Submit buttonto write a flag into a custom hidden field when you clicked on it. If the pageis refreshed - I thought - this code won't run, which gives the server page away 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 uponrefreshing the page. As you probably experienced on your own, this isn't whatreally happens. When users hit the Refresh button, the browser blindly repeatsthe last HTTP verb, which it has cached. In doing so, the flag written in thehidden field the last time the Submit button was clicked on is sent over againand thus cancels any difference for the server page between refresh and submit.Another approach is needed.

Given the browser's behavior, trapping the Refresh buttonclick 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 aprevious 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." Ifthe 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 pagerefresh.

Try this solution. To implement it, you need a couple ofsession-state slots - I named them Ticket and LastTicketServed - and initializeboth to 0. The former contains the next ticket available for the session; thelatter references the last ticket served within the session. Each page sent tothe browser contains a unique ticket generated progressively from the currentvalue of the Ticket slot. The ticket for the request is stored in a hiddenfield named  __TICKET. This code snippetshows 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 ticketgreater than any previous ticket. The content of the __TICKET field is postedwith other form fields when the page originates a postback event or isrefreshed. (Note that hidden fields are treated as visible text fields and thata similar mechanism lies behind the management of the page's view state.) Onthe server, the ticket associated with the request is analyzed and comparedwith the last served ticket. If the request's ticket is greater than the lastserved, the request is processed for the first time - hence, a regular submit.

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

One possible way to implement a component that traps thepage 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 thatchecks the request ticket and determines whether the request is a regularsubmit or a page refresh.

The HTTP module intercepts the AcquireRequestState eventand checks the session state associated with the request. The ASP.NET HTTPruntime fires the AcquireRequestState event immediately after the session stateis bound to the request being processed. The HTTP module manages theLastTicketServed slot and - if the ticket is greater than 0 and greater than thelast ticket served - sets it to the value of the request ticket. Thecomparison's outcome is translated in a Boolean value (true if it's a pagerefresh, false if it's a submit operation) and stored in the context of therequest.

The HttpContext object represents the call context for therequest and is common to all the modules and handlers that work over therequest. The Items collection on the HttpContext object is a cargo collectionwhere you can store shared data. Note that the HttpContext has the samelifetime as the request. The HTTP module sets the IsRefresh item in the cargoand 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 todetermine whether the request comes as a new form submission or through a pagerefresh.

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

The sample code in thisarticle is available for download.

Sign up for the ITPro Today newsletter
Stay on top of the IT universe with commentary, news analysis, how-to's, and tips delivered to your inbox daily.

You May Also Like